mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 11:08:12 +00:00
Merge branch 'alpha' into dev
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedArrivalTime" INTEGER;
|
||||||
|
ALTER TABLE "passenger"."RouteStop" ADD COLUMN "plannedDepartureTime" INTEGER;
|
||||||
@@ -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);
|
||||||
@@ -1051,6 +1051,8 @@ model RouteStop {
|
|||||||
sequence Int
|
sequence Int
|
||||||
distanceKm Float?
|
distanceKm Float?
|
||||||
checkinMinutesBefore Int?
|
checkinMinutesBefore Int?
|
||||||
|
plannedArrivalTime DateTime?
|
||||||
|
plannedDepartureTime DateTime?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
route Route @relation(fields: [routeId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
|||||||
@@ -128,4 +128,13 @@ export class BookPackageDto {
|
|||||||
|
|
||||||
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
|
/** Number of child passengers (<5 years). Derived from passengers array if omitted. */
|
||||||
@ApiPropertyOptional({ example: 1 }) @IsOptional() @IsInt() @Min(0) childCount?: number;
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Currency } from '@prisma/client';
|
|||||||
import { BookingsService } from '../bookings/bookings.service';
|
import { BookingsService } from '../bookings/bookings.service';
|
||||||
import { GuestBookingService } from '../bookings/guest-booking.service';
|
import { GuestBookingService } from '../bookings/guest-booking.service';
|
||||||
import { AuditService } from '../../common/audit.service';
|
import { AuditService } from '../../common/audit.service';
|
||||||
|
import { computePaymentDeadline, CUTOFF_MINUTES } from '../../common/utils/payment-deadline.utils';
|
||||||
|
|
||||||
/** Package-specific fare rules */
|
/** Package-specific fare rules */
|
||||||
const PKG_MAX_ADULTS = 5;
|
const PKG_MAX_ADULTS = 5;
|
||||||
@@ -382,7 +383,10 @@ export class PackagesService {
|
|||||||
async book(dto: BookPackageDto, passengerId?: string) {
|
async book(dto: BookPackageDto, passengerId?: string) {
|
||||||
const pkg = await this.prisma.travelPackage.findUnique({
|
const pkg = await this.prisma.travelPackage.findUnique({
|
||||||
where: { id: dto.packageId },
|
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) throw new NotFoundException('Package not found');
|
||||||
if (pkg.status !== 'ACTIVE') throw new BadRequestException('Package is not available for booking');
|
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 {
|
return {
|
||||||
...booking,
|
...booking,
|
||||||
fareBreakdown: {
|
fareBreakdown: {
|
||||||
|
|||||||
@@ -1,38 +1,50 @@
|
|||||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
|
||||||
import { ReportsService } from './reports.service';
|
import { ReportsService } from "./reports.service";
|
||||||
import { GenerateReportDto } from './reports.dto';
|
import { GenerateReportDto } from "./reports.dto";
|
||||||
import { PassengerStaff } from '../../common/passenger-guards';
|
import { PassengerStaff } from "../../common/passenger-guards";
|
||||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||||
|
|
||||||
@ApiTags('Reports')
|
@ApiTags("Reports")
|
||||||
@Controller('reports')
|
@Controller("reports")
|
||||||
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||||
@ApiBearerAuth('IAM-auth')
|
@ApiBearerAuth("IAM-auth")
|
||||||
export class ReportsController {
|
export class ReportsController {
|
||||||
constructor(private service: ReportsService) {}
|
constructor(private service: ReportsService) {}
|
||||||
|
|
||||||
@Post('generate')
|
@Post("generate")
|
||||||
@ApiOperation({ summary: 'Generate operational report' })
|
@ApiOperation({ summary: "Generate operational report" })
|
||||||
generateReport(@Body() dto: GenerateReportDto) {
|
generateReport(@Body() dto: GenerateReportDto) {
|
||||||
return this.service.generateReport(dto);
|
return this.service.generateReport(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('passengers')
|
@Get("schedules")
|
||||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
@ApiOperation({ summary: "List schedules for the passengers report picker" })
|
||||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
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);
|
return this.service.getOccupancyBySchedule(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':reportId')
|
@Get(":reportId")
|
||||||
@ApiOperation({ summary: 'Get report by ID' })
|
@ApiOperation({ summary: "Get report by ID" })
|
||||||
getReport(@Param('reportId') reportId: string) {
|
getReport(@Param("reportId") reportId: string) {
|
||||||
return this.service.getReport(reportId);
|
return this.service.getReport(reportId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOperation({ summary: 'List reports' })
|
@ApiOperation({ summary: "List reports" })
|
||||||
listReports(@Query('type') type?: string) {
|
listReports(@Query("type") type?: string) {
|
||||||
return this.service.listReports(type);
|
return this.service.listReports(type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from "@nestjs/common";
|
||||||
import { InjectDataSource } from '@nestjs/typeorm';
|
import { InjectDataSource } from "@nestjs/typeorm";
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from "typeorm";
|
||||||
import { PrismaService } from '../../common/prisma.service';
|
import { PrismaService } from "../../common/prisma.service";
|
||||||
import { GenerateReportDto, ReportType } from './reports.dto';
|
import { GenerateReportDto, ReportType } from "./reports.dto";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ReportsService {
|
export class ReportsService {
|
||||||
@@ -28,7 +28,11 @@ export class ReportsService {
|
|||||||
data = await this.generateOccupancyReport(dateFrom, dateTo);
|
data = await this.generateOccupancyReport(dateFrom, dateTo);
|
||||||
break;
|
break;
|
||||||
case ReportType.AGENT_SALES:
|
case ReportType.AGENT_SALES:
|
||||||
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
|
data = await this.generateAgentSalesReport(
|
||||||
|
dateFrom,
|
||||||
|
dateTo,
|
||||||
|
dto.agentId,
|
||||||
|
);
|
||||||
break;
|
break;
|
||||||
case ReportType.CANCELLATIONS:
|
case ReportType.CANCELLATIONS:
|
||||||
data = await this.generateCancellationsReport(dateFrom, dateTo);
|
data = await this.generateCancellationsReport(dateFrom, dateTo);
|
||||||
@@ -45,8 +49,8 @@ export class ReportsService {
|
|||||||
reportType: dto.reportType,
|
reportType: dto.reportType,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
data
|
data,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { reportId: report.id, reportType: dto.reportType, 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
|
// Fetch all bookings in date range, regardless of status
|
||||||
const bookings = await this.prisma.booking.findMany({
|
const bookings = await this.prisma.booking.findMany({
|
||||||
where: {
|
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 totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
const byPaymentMethod = bookings.reduce(
|
||||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
(acc, b) => {
|
||||||
acc[method] = (acc[method] || 0) + b.totalMinor;
|
const method = b.paymentIntent?.method ?? "UNKNOWN";
|
||||||
return acc;
|
acc[method] = (acc[method] || 0) + b.totalMinor;
|
||||||
}, {} as Record<string, number>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, number>,
|
||||||
|
);
|
||||||
|
|
||||||
// Group by date for charts
|
// Group by date for charts
|
||||||
const byDate = bookings.reduce((acc, b) => {
|
const byDate = bookings.reduce(
|
||||||
const date = b.createdAt.toISOString().split('T')[0];
|
(acc, b) => {
|
||||||
if (!acc[date]) {
|
const date = b.createdAt.toISOString().split("T")[0];
|
||||||
acc[date] = { totalMinor: 0, count: 0 };
|
if (!acc[date]) {
|
||||||
}
|
acc[date] = { totalMinor: 0, count: 0 };
|
||||||
acc[date].totalMinor += b.totalMinor;
|
}
|
||||||
acc[date].count += 1;
|
acc[date].totalMinor += b.totalMinor;
|
||||||
return acc;
|
acc[date].count += 1;
|
||||||
}, {} as Record<string, any>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalBookings: bookings.length,
|
totalBookings: bookings.length,
|
||||||
totalRevenueMinor: totalRevenue,
|
totalRevenueMinor: totalRevenue,
|
||||||
totalRevenue: totalRevenue / 100,
|
totalRevenue: totalRevenue / 100,
|
||||||
currency: 'ETB',
|
currency: "ETB",
|
||||||
byPaymentMethod,
|
byPaymentMethod,
|
||||||
byDate,
|
byDate,
|
||||||
cancellationRate: 0
|
cancellationRate: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,79 +106,115 @@ export class ReportsService {
|
|||||||
include: {
|
include: {
|
||||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||||
bookings: {
|
bookings: {
|
||||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||||
include: { seats: true },
|
include: { seats: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const tripData = schedules.map(schedule => {
|
const tripData = schedules.map((schedule) => {
|
||||||
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
|
const totalSeats = schedule.coachAssignments.reduce(
|
||||||
const bookedSeats = schedule.bookings.reduce(
|
(sum, a) => sum + a.coach.seats.length,
|
||||||
(sum, b) => sum + b.seats.filter((s: any) => s.leg === 1).length, 0,
|
0,
|
||||||
);
|
);
|
||||||
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
const bookedSeats = schedule.bookings.reduce(
|
||||||
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
|
(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;
|
const avgOccupancy =
|
||||||
return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData };
|
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({
|
const agentBookings = await this.prisma.agentBooking.findMany({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { gte: dateFrom, lte: dateTo },
|
createdAt: { gte: dateFrom, lte: dateTo },
|
||||||
...(agentId ? { agentId } : {})
|
...(agentId ? { agentId } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
agent: { select: { id: true, iamUserId: true, agentCode: true } },
|
agent: { select: { id: true, iamUserId: true, agentCode: true } },
|
||||||
booking: true
|
booking: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const iamUserIds = [...new Set(
|
const iamUserIds = [
|
||||||
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
|
...new Set(
|
||||||
)];
|
agentBookings
|
||||||
const iamRows = iamUserIds.length > 0
|
.map((ab) => ab.agent.iamUserId)
|
||||||
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
|
.filter(Boolean) as string[],
|
||||||
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
|
),
|
||||||
[iamUserIds],
|
];
|
||||||
)
|
const iamRows =
|
||||||
: [];
|
iamUserIds.length > 0
|
||||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
? 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 byAgent = agentBookings.reduce(
|
||||||
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
|
(acc, ab) => {
|
||||||
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
const iam = ab.agent.iamUserId
|
||||||
if (!acc[agentName]) {
|
? iamMap.get(ab.agent.iamUserId)
|
||||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
: undefined;
|
||||||
}
|
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
||||||
acc[agentName].bookings += 1;
|
if (!acc[agentName]) {
|
||||||
acc[agentName].revenueMinor += ab.booking.totalMinor;
|
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||||
acc[agentName].cashCollected += ab.cashReceived ?? 0;
|
}
|
||||||
return acc;
|
acc[agentName].bookings += 1;
|
||||||
}, {} as Record<string, any>);
|
acc[agentName].revenueMinor += ab.booking.totalMinor;
|
||||||
|
acc[agentName].cashCollected += ab.cashReceived ?? 0;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalAgentBookings: agentBookings.length,
|
totalAgentBookings: agentBookings.length,
|
||||||
byAgent
|
byAgent,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
|
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
|
||||||
const cancellations = await this.prisma.bookingCancellation.findMany({
|
const cancellations = await this.prisma.bookingCancellation.findMany({
|
||||||
where: { createdAt: { gte: dateFrom, lte: dateTo } },
|
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 {
|
return {
|
||||||
totalCancellations: cancellations.length,
|
totalCancellations: cancellations.length,
|
||||||
totalRefundedMinor: totalRefunded,
|
totalRefundedMinor: totalRefunded,
|
||||||
totalRefunded: totalRefunded / 100,
|
totalRefunded: totalRefunded / 100,
|
||||||
currency: 'ETB'
|
currency: "ETB",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,23 +222,26 @@ export class ReportsService {
|
|||||||
const payments = await this.prisma.paymentIntent.findMany({
|
const payments = await this.prisma.paymentIntent.findMany({
|
||||||
where: {
|
where: {
|
||||||
createdAt: { gte: dateFrom, lte: dateTo },
|
createdAt: { gte: dateFrom, lte: dateTo },
|
||||||
status: 'SUCCEEDED'
|
status: "SUCCEEDED",
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const byMethod = payments.reduce((acc, p) => {
|
const byMethod = payments.reduce(
|
||||||
const method = p.method;
|
(acc, p) => {
|
||||||
if (!acc[method]) {
|
const method = p.method;
|
||||||
acc[method] = { count: 0, totalMinor: 0 };
|
if (!acc[method]) {
|
||||||
}
|
acc[method] = { count: 0, totalMinor: 0 };
|
||||||
acc[method].count += 1;
|
}
|
||||||
acc[method].totalMinor += p.amountMinor;
|
acc[method].count += 1;
|
||||||
return acc;
|
acc[method].totalMinor += p.amountMinor;
|
||||||
}, {} as Record<string, any>);
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, any>,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
totalPayments: payments.length,
|
totalPayments: payments.length,
|
||||||
byMethod
|
byMethod,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,34 +263,48 @@ export class ReportsService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
bookings: {
|
bookings: {
|
||||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||||
include: {
|
include: {
|
||||||
seats: {
|
seats: {
|
||||||
where: { leg: 1 },
|
where: { scheduleId },
|
||||||
include: {
|
include: {
|
||||||
seat: { include: { coach: { include: { coachType: true } } } },
|
seat: { include: { coach: { include: { coachType: true } } } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!schedule) return null;
|
if (!schedule) return null;
|
||||||
|
|
||||||
const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0);
|
const totalSeats = (schedule as any).coachAssignments.reduce(
|
||||||
const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats);
|
(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 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
|
// 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) {
|
for (const assignment of (schedule as any).coachAssignments) {
|
||||||
const c = assignment.coach;
|
const c = assignment.coach;
|
||||||
coachMap.set(c.id, {
|
coachMap.set(c.id, {
|
||||||
coachNumber: c.number,
|
coachNumber: c.number,
|
||||||
coachType: (c as any).coachType?.name ?? 'Unknown',
|
coachType: (c as any).coachType?.name ?? "Unknown",
|
||||||
totalSeats: c.seats.length,
|
totalSeats: c.seats.length,
|
||||||
booked: 0,
|
booked: 0,
|
||||||
});
|
});
|
||||||
@@ -250,56 +313,91 @@ export class ReportsService {
|
|||||||
const coachId = bs.seat?.coachId;
|
const coachId = bs.seat?.coachId;
|
||||||
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||||
}
|
}
|
||||||
const byCoach = [...coachMap.values()].map(c => ({
|
const byCoach = [...coachMap.values()].map((c) => ({
|
||||||
...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)
|
// 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) {
|
for (const booking of (schedule as any).bookings) {
|
||||||
const stationId = booking.originStationId ?? schedule.originStationId;
|
const stationId = booking.originStationId ?? schedule.originStationId;
|
||||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
const stationName =
|
||||||
?? (schedule as any).originStation?.name
|
(schedule as any).stopTimes.find(
|
||||||
?? stationId;
|
(st: any) => st.stationId === stationId,
|
||||||
if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 });
|
)?.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;
|
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
|
// 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) {
|
for (const booking of (schedule as any).bookings) {
|
||||||
const stationId = booking.destinationStationId ?? schedule.destinationStationId;
|
const stationId =
|
||||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
booking.destinationStationId ?? schedule.destinationStationId;
|
||||||
?? (schedule as any).destinationStation?.name
|
const stationName =
|
||||||
?? stationId;
|
(schedule as any).stopTimes.find(
|
||||||
if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 });
|
(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;
|
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
|
// 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) {
|
for (const assignment of (schedule as any).coachAssignments) {
|
||||||
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
|
const typeName = (assignment.coach as any).coachType?.name ?? "Unknown";
|
||||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
if (!classMap.has(typeName))
|
||||||
|
classMap.set(typeName, {
|
||||||
|
className: typeName,
|
||||||
|
totalSeats: 0,
|
||||||
|
booked: 0,
|
||||||
|
});
|
||||||
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
||||||
}
|
}
|
||||||
for (const bs of allBookingSeats) {
|
for (const bs of allBookingSeats) {
|
||||||
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
|
const typeName = bs.seat?.coach?.coachType?.name ?? "Unknown";
|
||||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
if (!classMap.has(typeName))
|
||||||
|
classMap.set(typeName, {
|
||||||
|
className: typeName,
|
||||||
|
totalSeats: 0,
|
||||||
|
booked: 0,
|
||||||
|
});
|
||||||
classMap.get(typeName)!.booked++;
|
classMap.get(typeName)!.booked++;
|
||||||
}
|
}
|
||||||
const byClass = [...classMap.values()].map(c => ({
|
const byClass = [...classMap.values()].map((c) => ({
|
||||||
...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 {
|
return {
|
||||||
schedule: {
|
schedule: {
|
||||||
id: schedule.id,
|
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,
|
origin: (schedule as any).originStation?.name,
|
||||||
destination: (schedule as any).destinationStation?.name,
|
destination: (schedule as any).destinationStation?.name,
|
||||||
departureAt: schedule.departureAt,
|
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) {
|
async getReport(reportId: string) {
|
||||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
return this.prisma.operationalReport.findUnique({
|
||||||
|
where: { id: reportId },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listReports(reportType?: string) {
|
async listReports(reportType?: string) {
|
||||||
return this.prisma.operationalReport.findMany({
|
return this.prisma.operationalReport.findMany({
|
||||||
where: reportType ? { reportType } : {},
|
where: reportType ? { reportType } : {},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: "desc" },
|
||||||
take: 50
|
take: 50,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export class RouteStopInputDto {
|
|||||||
@ApiProperty({ example: 1, description: 'Stop order (1 = origin, ascending)' }) @IsInt() @Min(1) sequence: number;
|
@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: 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: 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 {
|
export class CreateRouteDto {
|
||||||
@@ -37,6 +39,8 @@ export class AddRouteStopDto {
|
|||||||
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
@ApiProperty({ example: 3 }) @IsInt() @Min(1) sequence: number;
|
||||||
@ApiPropertyOptional({ example: 75.5 }) @IsOptional() @IsNumber() distanceKm?: 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: 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 {
|
export class UpdateRouteDto {
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ export class RoutesService {
|
|||||||
sequence: s.sequence,
|
sequence: s.sequence,
|
||||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||||
checkinMinutesBefore: s.checkinMinutesBefore ?? 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,
|
sequence: s.sequence,
|
||||||
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
distanceKm: s.distanceKm != null ? parseFloat(String(s.distanceKm)) : null,
|
||||||
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
checkinMinutesBefore: s.checkinMinutesBefore ?? null,
|
||||||
|
plannedArrivalTime: s.plannedArrivalTime ?? null,
|
||||||
|
plannedDepartureTime: s.plannedDepartureTime ?? null,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -225,6 +229,8 @@ export class RoutesService {
|
|||||||
sequence: dto.sequence,
|
sequence: dto.sequence,
|
||||||
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
distanceKm: dto.distanceKm != null ? parseFloat(String(dto.distanceKm)) : null,
|
||||||
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
checkinMinutesBefore: dto.checkinMinutesBefore ?? null,
|
||||||
|
plannedArrivalTime: dto.plannedArrivalTime ?? null,
|
||||||
|
plannedDepartureTime: dto.plannedDepartureTime ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ export class UpdateScheduleDto {
|
|||||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
@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({ 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({ 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 {
|
export class UpdateStopTimeDto {
|
||||||
|
|||||||
@@ -133,26 +133,62 @@ export class SchedulesService {
|
|||||||
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
const totalDuration = arr.getTime() - dep.getTime();
|
const hasRouteTimes = route.stops.some(
|
||||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
|
||||||
|
);
|
||||||
|
|
||||||
plannedTimes = route.stops.map((stop, index) => {
|
if (hasRouteTimes) {
|
||||||
let stopTime: Date;
|
// Extract EAT time-of-day from a template DateTime and anchor to the schedule's EAT date.
|
||||||
if (index === 0) {
|
const EAT_MS = 3 * 60 * 60 * 1000;
|
||||||
stopTime = dep;
|
const depEATMs = dep.getTime() + EAT_MS;
|
||||||
} else if (index === route.stops.length - 1) {
|
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
|
||||||
stopTime = arr;
|
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
|
||||||
} else {
|
|
||||||
const stopDistance = stop.distanceKm || 0;
|
const templateToScheduleUTC = (templateDt: Date): Date => {
|
||||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
// Pull the time-of-day in EAT from the template DateTime
|
||||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
const templateEATMs = templateDt.getTime() + EAT_MS;
|
||||||
}
|
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
|
||||||
return {
|
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
|
||||||
sequence: stop.sequence,
|
// Overnight: if the stop time lands before departure, move to next day
|
||||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
|
||||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
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));
|
const providedSeqs = new Set((plannedTimes ?? []).map(t => t.sequence));
|
||||||
@@ -304,26 +340,59 @@ export class SchedulesService {
|
|||||||
|
|
||||||
let plannedTimes = dto.plannedTimes;
|
let plannedTimes = dto.plannedTimes;
|
||||||
if (!plannedTimes || plannedTimes.length === 0) {
|
if (!plannedTimes || plannedTimes.length === 0) {
|
||||||
const totalDuration = arr.getTime() - dep.getTime();
|
const hasRouteTimes = route.stops.some(
|
||||||
const totalDistance = route.stops[route.stops.length - 1].distanceKm || 0;
|
s => (s as any).plannedArrivalTime != null || (s as any).plannedDepartureTime != null,
|
||||||
|
);
|
||||||
|
|
||||||
plannedTimes = route.stops.map((stop, index) => {
|
if (hasRouteTimes) {
|
||||||
let stopTime: Date;
|
const EAT_MS = 3 * 60 * 60 * 1000;
|
||||||
if (index === 0) {
|
const depEATMs = dep.getTime() + EAT_MS;
|
||||||
stopTime = dep;
|
const depMsIntoDay = depEATMs % (24 * 60 * 60 * 1000);
|
||||||
} else if (index === route.stops.length - 1) {
|
const eatMidnightUTC = dep.getTime() - depMsIntoDay;
|
||||||
stopTime = arr;
|
|
||||||
} else {
|
const templateToScheduleUTC = (templateDt: Date): Date => {
|
||||||
const stopDistance = stop.distanceKm || 0;
|
const templateEATMs = templateDt.getTime() + EAT_MS;
|
||||||
const progress = totalDistance > 0 ? stopDistance / totalDistance : index / (route.stops.length - 1);
|
const timeOfDayMs = templateEATMs % (24 * 60 * 60 * 1000);
|
||||||
stopTime = new Date(dep.getTime() + totalDuration * progress);
|
const candidate = new Date(eatMidnightUTC + timeOfDayMs);
|
||||||
}
|
if (candidate < dep) return new Date(candidate.getTime() + 24 * 60 * 60 * 1000);
|
||||||
return {
|
return candidate;
|
||||||
sequence: stop.sequence,
|
|
||||||
plannedArrivalAt: index === 0 ? undefined : stopTime.toISOString(),
|
|
||||||
plannedDepartureAt: index === route.stops.length - 1 ? undefined : stopTime.toISOString(),
|
|
||||||
};
|
};
|
||||||
});
|
|
||||||
|
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]));
|
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);
|
return this.getSchedule(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -617,9 +617,6 @@ export class SeatsService {
|
|||||||
|
|
||||||
async getBlockedSeats() {
|
async getBlockedSeats() {
|
||||||
const blocks = await this.prisma.seatBlock.findMany({
|
const blocks = await this.prisma.seatBlock.findMany({
|
||||||
where: {
|
|
||||||
NOT: { reason: { startsWith: 'MAINTENANCE:' } },
|
|
||||||
},
|
|
||||||
include: {
|
include: {
|
||||||
seat: { include: { coach: { select: { number: true } } } },
|
seat: { include: { coach: { select: { number: true } } } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -146,6 +146,7 @@ export class TasksService {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
this.sendPaymentReminders(now),
|
this.sendPaymentReminders(now),
|
||||||
this.cancelExpiredPendingBookings(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.
|
// Daily at 02:00 EAT: purge expired/stale records to enforce data retention.
|
||||||
// ─────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
"lucide-react": "^0.446.0",
|
"lucide-react": "^0.446.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
|
"react-day-picker": "^9.14.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"recharts": "^2.12.0",
|
"recharts": "^2.12.0",
|
||||||
"socket.io-client": "^4.8.3",
|
"socket.io-client": "^4.8.3",
|
||||||
|
|||||||
@@ -1,48 +1,77 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
import { PermissionGuard } from "@/components/layout/PermissionGuard";
|
||||||
import { PERMS } from '@/lib/permissions';
|
import { PERMS } from "@/lib/permissions";
|
||||||
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react';
|
import {
|
||||||
import { dashboardApi } from '@/lib/api/dashboard';
|
Ticket,
|
||||||
import { apiClient } from '@/lib/api-client';
|
AlertCircle,
|
||||||
import { formatCurrency } from '@/lib/utils';
|
BookOpen,
|
||||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
Banknote,
|
||||||
import Link from 'next/link';
|
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({
|
function StatCard({
|
||||||
icon, iconBg, label, total, loading, rows, href,
|
icon,
|
||||||
|
iconBg,
|
||||||
|
label,
|
||||||
|
total,
|
||||||
|
loading,
|
||||||
|
rows,
|
||||||
|
href,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
iconBg: string;
|
iconBg: string;
|
||||||
label: string;
|
label: string;
|
||||||
total: number;
|
total: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
|
rows: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
href: string;
|
||||||
|
}[];
|
||||||
href: string;
|
href: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="card flex flex-col gap-3">
|
<div className="card flex flex-col gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
|
<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>
|
</div>
|
||||||
<p className="text-3xl font-bold text-foreground tabular-nums">
|
<p className="text-3xl font-bold text-foreground tabular-nums">
|
||||||
{loading ? '—' : total.toLocaleString()}
|
{loading ? "—" : total.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
{rows.map((r) => (
|
{rows.map((r) => (
|
||||||
<div key={r.label} className="flex items-center justify-between">
|
<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>
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
|
{r.icon}
|
||||||
{loading ? '—' : r.value.toLocaleString()}
|
{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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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" />
|
View all <ArrowRight className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -50,7 +79,12 @@ function StatCard({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RevenueSection({
|
function RevenueSection({
|
||||||
label, bookingCount, rows, subtotal, loading, renderRow,
|
label,
|
||||||
|
bookingCount,
|
||||||
|
rows,
|
||||||
|
subtotal,
|
||||||
|
loading,
|
||||||
|
renderRow,
|
||||||
}: {
|
}: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
bookingCount: number;
|
bookingCount: number;
|
||||||
@@ -66,16 +100,22 @@ function RevenueSection({
|
|||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground tabular-nums">
|
<span className="text-xs text-muted-foreground tabular-nums">
|
||||||
{loading ? '—' : bookingCount.toLocaleString()} bookings
|
{loading ? "—" : bookingCount.toLocaleString()} bookings
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{rows.length === 0
|
{rows.length === 0 ? (
|
||||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
<p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||||
: rows.map(renderRow)}
|
) : (
|
||||||
|
rows.map(renderRow)
|
||||||
|
)}
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
|
<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-xs font-semibold text-muted-foreground">
|
||||||
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
|
Subtotal
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-foreground tabular-nums">
|
||||||
|
{formatCurrency(subtotal, "ETB")}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -84,19 +124,25 @@ function RevenueSection({
|
|||||||
|
|
||||||
function DashboardPageContent() {
|
function DashboardPageContent() {
|
||||||
const { data: exchangeRates = [] } = useQuery<any[]>({
|
const { data: exchangeRates = [] } = useQuery<any[]>({
|
||||||
queryKey: ['currencies'],
|
queryKey: ["currencies"],
|
||||||
queryFn: () => apiClient.get('/currencies'),
|
queryFn: () => apiClient.get("/currencies"),
|
||||||
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
|
select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])),
|
||||||
});
|
});
|
||||||
|
|
||||||
const toEtbRate = (currency: string): number | null => {
|
const toEtbRate = (currency: string): number | null => {
|
||||||
if (currency === 'ETB') return 1;
|
if (currency === "ETB") return 1;
|
||||||
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
|
const r = exchangeRates.find(
|
||||||
|
(x: any) => x.fromCurrency === "ETB" && x.toCurrency === currency,
|
||||||
|
);
|
||||||
return r ? 1 / r.rate : null;
|
return r ? 1 / r.rate : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
|
const {
|
||||||
queryKey: ['backoffice-stats'],
|
data: stats,
|
||||||
|
isLoading: statsLoading,
|
||||||
|
error: statsError,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["backoffice-stats"],
|
||||||
queryFn: dashboardApi.getBackofficeStats,
|
queryFn: dashboardApi.getBackofficeStats,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
staleTime: 30000,
|
staleTime: 30000,
|
||||||
@@ -104,7 +150,7 @@ function DashboardPageContent() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: paymentMethods } = useQuery({
|
const { data: paymentMethods } = useQuery({
|
||||||
queryKey: ['payment-methods'],
|
queryKey: ["payment-methods"],
|
||||||
queryFn: dashboardApi.getPaymentMethods,
|
queryFn: dashboardApi.getPaymentMethods,
|
||||||
retry: 1,
|
retry: 1,
|
||||||
});
|
});
|
||||||
@@ -121,20 +167,31 @@ function DashboardPageContent() {
|
|||||||
const packageGrand = calcGrand(packageRows);
|
const packageGrand = calcGrand(packageRows);
|
||||||
const overallGrand = normalGrand + packageGrand;
|
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 rate = toEtbRate(currency);
|
||||||
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
|
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
|
||||||
return (
|
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">
|
<div className="flex items-center gap-1.5">
|
||||||
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
|
<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>
|
</div>
|
||||||
<span className="text-sm font-semibold text-foreground tabular-nums">
|
<span className="text-sm font-semibold text-foreground tabular-nums">
|
||||||
{formatCurrency(totalMinor, currency)}
|
{formatCurrency(totalMinor, currency)}
|
||||||
{currency !== 'ETB' && etbMinor !== null && (
|
{currency !== "ETB" && etbMinor !== null && (
|
||||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||||
({formatCurrency(etbMinor, 'ETB')})
|
({formatCurrency(etbMinor, "ETB")})
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
@@ -147,15 +204,16 @@ function DashboardPageContent() {
|
|||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||||
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Welcome back! Here's your operational summary.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/boarding"
|
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"
|
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"
|
||||||
title="Boarding / Gate Scan"
|
|
||||||
>
|
>
|
||||||
<ScanLine className="h-4 w-4 text-[rgb(20,113,76)]" />
|
<ScanLine className="h-4 w-4" />
|
||||||
<span className="hidden sm:inline">Boarding</span>
|
Boarding
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -164,8 +222,12 @@ function DashboardPageContent() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="font-semibold text-orange-800 dark:text-orange-200">Some data may be outdated</h3>
|
<h3 className="font-semibold text-orange-800 dark:text-orange-200">
|
||||||
<p className="text-sm text-orange-700 dark:text-orange-300">Unable to fetch live data. Showing cached or sample information.</p>
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -174,15 +236,25 @@ function DashboardPageContent() {
|
|||||||
{/* Stat cards */}
|
{/* Stat cards */}
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
<StatCard
|
<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"
|
iconBg="bg-blue-100 dark:bg-blue-900/30"
|
||||||
label="Bookings"
|
label="Bookings"
|
||||||
total={stats?.totalBookings ?? 0}
|
total={stats?.totalBookings ?? 0}
|
||||||
loading={statsLoading}
|
loading={statsLoading}
|
||||||
href="/bookings"
|
href="/bookings"
|
||||||
rows={[
|
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 */}
|
{/* 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">
|
<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" />
|
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
||||||
{formatCurrency(overallGrand, 'ETB')}
|
{formatCurrency(overallGrand, "ETB")}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-muted-foreground">Regular</p>
|
<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>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs text-muted-foreground">Package</p>
|
<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>
|
||||||
</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" />
|
View payments <ArrowRight className="h-3 w-3" />
|
||||||
</Link>
|
</Link>
|
||||||
</>
|
</>
|
||||||
@@ -222,7 +303,9 @@ function DashboardPageContent() {
|
|||||||
|
|
||||||
{/* Revenue breakdown */}
|
{/* Revenue breakdown */}
|
||||||
<div className="card">
|
<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 ? (
|
{statsLoading ? (
|
||||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||||
) : !normalRows.length && !packageRows.length ? (
|
) : !normalRows.length && !packageRows.length ? (
|
||||||
@@ -252,12 +335,25 @@ function DashboardPageContent() {
|
|||||||
{/* Payment Methods Distribution */}
|
{/* Payment Methods Distribution */}
|
||||||
{paymentMethods && paymentMethods.length > 0 && (
|
{paymentMethods && paymentMethods.length > 0 && (
|
||||||
<div className="card">
|
<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}>
|
<ResponsiveContainer width="100%" height={300}>
|
||||||
<PieChart>
|
<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) => (
|
{paymentMethods.map((_: any, index: number) => (
|
||||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
<Cell
|
||||||
|
key={`cell-${index}`}
|
||||||
|
fill={COLORS[index % COLORS.length]}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</Pie>
|
</Pie>
|
||||||
<Tooltip />
|
<Tooltip />
|
||||||
|
|||||||
@@ -1,21 +1,53 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react';
|
import { Users, Armchair, TrendingUp, Train, Download } from "lucide-react";
|
||||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
import {
|
||||||
import { apiClient } from '@/lib/api-client';
|
BarChart,
|
||||||
import { formatDateTime } from '@/lib/utils';
|
Bar,
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
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 (
|
return (
|
||||||
<div className="card flex flex-col gap-1">
|
<div className="card flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</p>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<div className={`rounded-lg p-1.5 ${color}`}><Icon className="h-4 w-4" /></div>
|
{label}
|
||||||
|
</p>
|
||||||
|
<div className={`rounded-lg p-1.5 ${color}`}>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
|
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
|
||||||
{sub && <p className="text-xs text-muted-foreground">{sub}</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 {
|
interface PassengerRow {
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
|
bookingStatus: string;
|
||||||
passengerName: string;
|
passengerName: string;
|
||||||
coachSeat: string;
|
passengerCategory: string;
|
||||||
origin: string;
|
idDocumentType: string | null;
|
||||||
destination: string;
|
idDocumentNumber: string | null;
|
||||||
departureAt: 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() {
|
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[]>({
|
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
||||||
queryKey: ['schedules-list'],
|
ScheduleOption[]
|
||||||
queryFn: () => apiClient.get('/schedules'),
|
>({
|
||||||
select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []),
|
queryKey: ["report-schedules"],
|
||||||
|
queryFn: () => apiClient.get("/reports/schedules"),
|
||||||
});
|
});
|
||||||
|
const schedules = schedulesRaw ?? [];
|
||||||
|
|
||||||
const { data, isFetching } = useQuery({
|
const { data, isLoading, isError } = useQuery<PassengersReport>({
|
||||||
queryKey: ['occupancy-report', scheduleId],
|
queryKey: ["passengers-report", scheduleId],
|
||||||
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
queryFn: () =>
|
||||||
|
apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
||||||
enabled: !!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 = () => {
|
const filteredList = listSearch.trim()
|
||||||
if (!report) return;
|
? passengerList.filter(
|
||||||
const rows = [
|
(p) =>
|
||||||
['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy %'],
|
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
|
||||||
...report.byCoach.map((c: any) => [c.coachNumber, c.coachType, c.totalSeats, c.booked, c.occupancyRate]),
|
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) ||
|
||||||
];
|
(p.idDocumentNumber ?? "")
|
||||||
const csv = rows.map(r => r.map((v: any) => `"${v}"`).join(',')).join('\n');
|
.toLowerCase()
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
.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 url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
a.href = url;
|
||||||
a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`;
|
a.download = filename;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
|
|
||||||
const doExportOccupancy = () => {
|
const doExportOccupancy = () => {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
const rows = data.byCoach.map(c => [c.coachNumber, c.coachType, String(c.totalSeats), String(c.booked), `${c.occupancyRate}%`]);
|
const rows = data.byCoach.map((c) => [
|
||||||
downloadCsv([['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy'].join(','), ...rows.map(r => r.join(','))].join('\n'), `occupancy-${scheduleId}.csv`);
|
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 = () => {
|
const doExportList = () => {
|
||||||
if (!passengerList.length) return;
|
if (!passengerList.length) return;
|
||||||
const headers = ['#', 'Name', 'Coach·Seat', 'Origin', 'Destination', 'Date', 'Booking Ref'];
|
const headers = [
|
||||||
const rows = passengerList.map((p, i) => [
|
"Booking Ref",
|
||||||
String(i + 1), p.passengerName, p.coachSeat, p.origin, p.destination,
|
"Status",
|
||||||
p.departureAt ? formatDateTime(p.departureAt) : '—', p.bookingRef,
|
"Name",
|
||||||
].map(v => `"${String(v).replace(/"/g, '""')}"`));
|
"Category",
|
||||||
downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`);
|
"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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
|
<h1 className="text-3xl font-bold text-foreground">
|
||||||
<p className="text-muted-foreground mt-1">Select a schedule to view passenger occupancy breakdown</p>
|
Passengers Report
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Select a schedule to view passenger occupancy breakdown
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Schedule Selector */}
|
{/* Schedule selector */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex flex-wrap items-end gap-4">
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
<div className="flex-1 min-w-64">
|
<div className="flex-1 min-w-64">
|
||||||
@@ -98,27 +197,54 @@ export default function PassengersReportPage() {
|
|||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={scheduleId}
|
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>
|
<option value="">
|
||||||
{schedules.map((s: any) => (
|
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
|
||||||
|
</option>
|
||||||
|
{schedules.map((s) => (
|
||||||
<option key={s.id} value={s.id}>
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{isFetching && <p className="text-sm text-muted-foreground self-center">Loading…</p>}
|
{data && tab === "occupancy" && (
|
||||||
{report && (
|
<ActionButton
|
||||||
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
|
icon={Download}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={doExportOccupancy}
|
||||||
|
>
|
||||||
|
Export CSV
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
{passengerList.length > 0 && tab === "list" && (
|
||||||
|
<ActionButton
|
||||||
|
icon={Download}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={doExportList}
|
||||||
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{isFetching && (
|
{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 && (
|
{report && (
|
||||||
@@ -131,205 +257,305 @@ export default function PassengersReportPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="font-semibold">{report.schedule.trainName}</p>
|
<p className="font-semibold">{report.schedule.trainName}</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Summary Cards */}
|
{/* Tabs */}
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
<div className="border-b border-border flex">
|
||||||
<StatCard
|
<button
|
||||||
label="Total Seats"
|
onClick={() => setTab("occupancy")}
|
||||||
value={report.summary.totalSeats}
|
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"}`}
|
||||||
icon={Armchair}
|
>
|
||||||
color="bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
|
Occupancy
|
||||||
/>
|
</button>
|
||||||
<StatCard
|
<button
|
||||||
label="Total Passengers"
|
onClick={() => setTab("list")}
|
||||||
value={report.summary.totalPassengers}
|
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"}`}
|
||||||
icon={Users}
|
>
|
||||||
color="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"
|
Passenger List
|
||||||
/>
|
{passengerList.length > 0 ? ` (${passengerList.length})` : ""}
|
||||||
<StatCard
|
</button>
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
{/* Occupancy tab */}
|
||||||
{/* By Coach */}
|
{tab === "occupancy" && (
|
||||||
<div className="card">
|
<div className="space-y-6">
|
||||||
<h3 className="text-base font-semibold mb-4">Occupancy by Coach</h3>
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
{report.byCoach.length > 0 ? (
|
<div className="card flex flex-col gap-1">
|
||||||
<>
|
<div className="flex items-center justify-between">
|
||||||
<ResponsiveContainer width="100%" height={220}>
|
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<BarChart data={report.byCoach} layout="vertical" margin={{ left: 8 }}>
|
Total Seats
|
||||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
</p>
|
||||||
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||||
<YAxis type="category" dataKey="coachNumber" tick={{ fontSize: 11 }} width={56} tickFormatter={(v) => `Coach ${v}`} />
|
<Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
|
</div>
|
||||||
<Bar dataKey="occupancyRate" radius={[0, 3, 3, 0]}>
|
</div>
|
||||||
{report.byCoach.map((_: any, i: number) => (
|
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
{data.summary.totalSeats}
|
||||||
))}
|
</p>
|
||||||
</Bar>
|
</div>
|
||||||
</BarChart>
|
<div className="card flex flex-col gap-1">
|
||||||
</ResponsiveContainer>
|
<div className="flex items-center justify-between">
|
||||||
<table className="w-full mt-3 text-sm">
|
<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>
|
<thead>
|
||||||
<tr className="text-xs text-muted-foreground border-b border-border">
|
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
<th className="text-left py-1.5 font-medium">Coach</th>
|
<th className="pb-2 pr-4">Coach</th>
|
||||||
<th className="text-left py-1.5 font-medium">Type</th>
|
<th className="pb-2 pr-4">Type</th>
|
||||||
<th className="text-right py-1.5 font-medium">Booked</th>
|
<th className="pb-2 pr-4 text-right">Seats</th>
|
||||||
<th className="text-right py-1.5 font-medium">Total</th>
|
<th className="pb-2 pr-4 text-right">Booked</th>
|
||||||
<th className="text-right py-1.5 font-medium">Rate</th>
|
<th className="pb-2">Occupancy</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-border">
|
||||||
{report.byCoach.map((c: any, i: number) => (
|
{data.byCoach.map((c) => (
|
||||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
<tr key={c.coachNumber} className="hover:bg-muted/30">
|
||||||
<td className="py-1.5 font-mono font-semibold">Coach {c.coachNumber}</td>
|
<td className="py-2 pr-4 font-semibold">
|
||||||
<td className="py-1.5 text-muted-foreground">{c.coachType}</td>
|
{c.coachNumber}
|
||||||
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
|
</td>
|
||||||
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
|
<td className="py-2 pr-4 text-muted-foreground">
|
||||||
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
|
{c.coachType}
|
||||||
</tr>
|
</td>
|
||||||
))}
|
<td className="py-2 pr-4 text-right tabular-nums">
|
||||||
</tbody>
|
{c.totalSeats}
|
||||||
</table>
|
</td>
|
||||||
</>
|
<td className="py-2 pr-4 text-right tabular-nums">
|
||||||
) : (
|
{c.booked}
|
||||||
<p className="text-sm text-muted-foreground">No coach data</p>
|
</td>
|
||||||
)}
|
|
||||||
</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">
|
|
||||||
<td className="py-2">
|
<td className="py-2">
|
||||||
<div className="flex items-center gap-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] }} />
|
<div className="flex-1 bg-muted rounded-full h-1.5">
|
||||||
{o.stationName}
|
<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>
|
</div>
|
||||||
</td>
|
</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>
|
</tr>
|
||||||
);
|
))}
|
||||||
})}
|
</tbody>
|
||||||
</tbody>
|
</table>
|
||||||
</table>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">No boarding station data</p>
|
|
||||||
)}
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* By Destination */}
|
{/* Passenger List tab */}
|
||||||
<div className="card">
|
{tab === "list" && (
|
||||||
<h3 className="text-base font-semibold mb-4">Passengers by Alighting Station</h3>
|
<div className="card space-y-4">
|
||||||
{report.byDestination.length > 0 ? (
|
<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">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs text-muted-foreground border-b border-border">
|
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||||
<th className="text-left py-1.5 font-medium">Station</th>
|
<th className="pb-2 pr-4">#</th>
|
||||||
<th className="text-right py-1.5 font-medium">Passengers</th>
|
<th className="pb-2 pr-4">Name</th>
|
||||||
<th className="text-right py-1.5 font-medium">Share</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-border">
|
||||||
{report.byDestination.map((d: any, i: number) => {
|
{filteredList.map((p, i) => (
|
||||||
const pct = report.summary.totalPassengers > 0
|
<tr
|
||||||
? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1)
|
key={`${p.bookingRef}-${i}`}
|
||||||
: '0';
|
className="hover:bg-muted/30"
|
||||||
return (
|
>
|
||||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
<td className="py-2 pr-4 text-muted-foreground tabular-nums">
|
||||||
<td className="py-2">
|
{i + 1}
|
||||||
<div className="flex items-center gap-2">
|
</td>
|
||||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
|
<td className="py-2 pr-4 font-medium">
|
||||||
{d.stationName}
|
{p.passengerName}
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td className="py-2 pr-4">
|
||||||
<td className="py-2 text-right tabular-nums font-semibold">{d.passengers}</td>
|
<span
|
||||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
|
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"}`}
|
||||||
</tr>
|
>
|
||||||
);
|
{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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">No alighting station data</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!report && !isFetching && scheduleId && (
|
{!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 && (
|
{!scheduleId && (
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react';
|
import {
|
||||||
import { bookingsApi } from '@/lib/api';
|
Download,
|
||||||
import { dashboardApi } from '@/lib/api/dashboard';
|
Armchair,
|
||||||
import Badge from '@/components/ui/Badge';
|
CheckCircle,
|
||||||
import ActionButton from '@/components/ui/ActionButton';
|
Clock,
|
||||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
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 {
|
interface SeatRow {
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
@@ -28,12 +34,15 @@ interface SeatRow {
|
|||||||
const HOLD_DURATION_MS = 5 * 60 * 1000;
|
const HOLD_DURATION_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
function getReleaseAt(booking: any, seat: any): string | null {
|
function getReleaseAt(booking: any, seat: any): string | null {
|
||||||
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
|
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||||
if (paymentStatus === 'SUCCEEDED' || paymentStatus === 'COMPLETED') return null;
|
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED")
|
||||||
if (booking.status === 'CONFIRMED') return null;
|
return null;
|
||||||
|
if (booking.status === "CONFIRMED") return null;
|
||||||
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
|
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
|
||||||
if (booking.createdAt) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -44,17 +53,21 @@ function isExpired(releaseAt: string | null): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function SeatStatusReportPage() {
|
export default function SeatStatusReportPage() {
|
||||||
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
|
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">(
|
||||||
const [search, setSearch] = useState('');
|
"ALL",
|
||||||
|
);
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const { data: stats } = useQuery({
|
const { data: blockedSeats = [] } = useQuery({
|
||||||
queryKey: ['backoffice-stats'],
|
queryKey: ["blocked-seats"],
|
||||||
queryFn: dashboardApi.getBackofficeStats,
|
queryFn: () =>
|
||||||
staleTime: 30000,
|
seatsApi
|
||||||
|
.getBlocked()
|
||||||
|
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: bookingsData, isLoading } = useQuery({
|
const { data: bookingsData, isLoading } = useQuery({
|
||||||
queryKey: ['seat-report-bookings'],
|
queryKey: ["seat-report-bookings"],
|
||||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,25 +76,30 @@ export default function SeatStatusReportPage() {
|
|||||||
const result: SeatRow[] = [];
|
const result: SeatRow[] = [];
|
||||||
|
|
||||||
for (const booking of bookings) {
|
for (const booking of bookings) {
|
||||||
if (booking.status === 'CANCELLED') continue;
|
if (booking.status === "CANCELLED") continue;
|
||||||
const seats: any[] = booking.seats || [];
|
const seats: any[] = booking.seats || [];
|
||||||
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
|
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||||
|
|
||||||
for (const seat of seats) {
|
for (const seat of seats) {
|
||||||
result.push({
|
result.push({
|
||||||
bookingRef: booking.bookingRef || '—',
|
bookingRef: booking.bookingRef || "—",
|
||||||
passengerName: seat.passengerName || seat.name || booking.passengerNames?.[0] || '—',
|
passengerName:
|
||||||
seatNumber: seat.seat?.seatNumber || seat.seatNumber || '—',
|
seat.passengerName ||
|
||||||
coachNumber: seat.seat?.coach?.number || seat.coach || '—',
|
seat.name ||
|
||||||
|
booking.passengerNames?.[0] ||
|
||||||
|
"—",
|
||||||
|
seatNumber: seat.seat?.seatNumber || seat.seatNumber || "—",
|
||||||
|
coachNumber: seat.seat?.coach?.number || seat.coach || "—",
|
||||||
fareMinor: seat.fareMinor ?? 0,
|
fareMinor: seat.fareMinor ?? 0,
|
||||||
currency: booking.currency || 'ETB',
|
currency: booking.currency || "ETB",
|
||||||
paymentStatus,
|
paymentStatus,
|
||||||
bookingStatus: booking.status,
|
bookingStatus: booking.status,
|
||||||
bookedAt: booking.createdAt,
|
bookedAt: booking.createdAt,
|
||||||
releaseAt: getReleaseAt(booking, seat),
|
releaseAt: getReleaseAt(booking, seat),
|
||||||
scheduleOrigin: booking.schedule?.originStation?.name || '—',
|
scheduleOrigin: booking.schedule?.originStation?.name || "—",
|
||||||
scheduleDestination: booking.schedule?.destinationStation?.name || '—',
|
scheduleDestination:
|
||||||
scheduleDeparture: booking.schedule?.departureAt || '',
|
booking.schedule?.destinationStation?.name || "—",
|
||||||
|
scheduleDeparture: booking.schedule?.departureAt || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,9 +109,10 @@ export default function SeatStatusReportPage() {
|
|||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filtered = useMemo(() => {
|
||||||
return rows.filter((r) => {
|
return rows.filter((r) => {
|
||||||
const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED';
|
const isPaid =
|
||||||
if (statusFilter === 'PAID' && !isPaid) return false;
|
r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED";
|
||||||
if (statusFilter === 'UNPAID' && isPaid) return false;
|
if (statusFilter === "PAID" && !isPaid) return false;
|
||||||
|
if (statusFilter === "UNPAID" && isPaid) return false;
|
||||||
if (search) {
|
if (search) {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -108,17 +127,29 @@ export default function SeatStatusReportPage() {
|
|||||||
}, [rows, statusFilter, search]);
|
}, [rows, statusFilter, search]);
|
||||||
|
|
||||||
const paidCount = rows.filter(
|
const paidCount = rows.filter(
|
||||||
(r) => r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'
|
(r) => r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED",
|
||||||
).length;
|
).length;
|
||||||
const unpaidCount = rows.length - paidCount;
|
const unpaidCount = rows.length - paidCount;
|
||||||
const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length;
|
const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length;
|
||||||
|
|
||||||
const doExport = () => {
|
const doExport = () => {
|
||||||
if (!filtered.length) { alert('No data to export'); return; }
|
if (!filtered.length) {
|
||||||
|
alert("No data to export");
|
||||||
|
return;
|
||||||
|
}
|
||||||
const headers = [
|
const headers = [
|
||||||
'Booking Ref', 'Passenger', 'Seat', 'Coach', 'Fare',
|
"Booking Ref",
|
||||||
'Payment Status', 'Booking Status', 'Booked At', 'Release At',
|
"Passenger",
|
||||||
'Origin', 'Destination', 'Departure',
|
"Seat",
|
||||||
|
"Coach",
|
||||||
|
"Fare",
|
||||||
|
"Payment Status",
|
||||||
|
"Booking Status",
|
||||||
|
"Booked At",
|
||||||
|
"Release At",
|
||||||
|
"Origin",
|
||||||
|
"Destination",
|
||||||
|
"Departure",
|
||||||
];
|
];
|
||||||
const csvRows = filtered.map((r) => [
|
const csvRows = filtered.map((r) => [
|
||||||
r.bookingRef,
|
r.bookingRef,
|
||||||
@@ -128,21 +159,21 @@ export default function SeatStatusReportPage() {
|
|||||||
formatCurrency(r.fareMinor, r.currency),
|
formatCurrency(r.fareMinor, r.currency),
|
||||||
r.paymentStatus,
|
r.paymentStatus,
|
||||||
r.bookingStatus,
|
r.bookingStatus,
|
||||||
r.bookedAt ? formatDateTime(r.bookedAt) : '—',
|
r.bookedAt ? formatDateTime(r.bookedAt) : "—",
|
||||||
r.releaseAt ? formatDateTime(r.releaseAt) : '—',
|
r.releaseAt ? formatDateTime(r.releaseAt) : "—",
|
||||||
r.scheduleOrigin,
|
r.scheduleOrigin,
|
||||||
r.scheduleDestination,
|
r.scheduleDestination,
|
||||||
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : '—',
|
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : "—",
|
||||||
]);
|
]);
|
||||||
const csv = [
|
const csv = [
|
||||||
headers.map((h) => `"${h}"`).join(','),
|
headers.map((h) => `"${h}"`).join(","),
|
||||||
...csvRows.map((row) => row.map((v) => `"${v}"`).join(',')),
|
...csvRows.map((row) => row.map((v) => `"${v}"`).join(",")),
|
||||||
].join('\n');
|
].join("\n");
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
const blob = new Blob([csv], { type: "text/csv" });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = url;
|
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();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
};
|
};
|
||||||
@@ -150,9 +181,12 @@ export default function SeatStatusReportPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,11 +195,15 @@ export default function SeatStatusReportPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<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">
|
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
|
||||||
{paidCount}
|
{paidCount}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
@@ -174,11 +212,15 @@ export default function SeatStatusReportPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<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">
|
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
|
||||||
{unpaidCount}
|
{unpaidCount}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
@@ -187,11 +229,15 @@ export default function SeatStatusReportPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<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">
|
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
|
||||||
{expiredCount}
|
{expiredCount}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
@@ -200,14 +246,38 @@ export default function SeatStatusReportPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
<p className="text-muted-foreground text-sm font-medium">
|
||||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
Blocked Seats
|
||||||
{stats?.blockedSeatsCount ?? '—'}
|
</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>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Globally blocked</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,18 +299,27 @@ export default function SeatStatusReportPage() {
|
|||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={statusFilter}
|
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="ALL">All Seats</option>
|
||||||
<option value="PAID">Paid Only</option>
|
<option value="PAID">Paid Only</option>
|
||||||
<option value="UNPAID">Unpaid Only</option>
|
<option value="UNPAID">Unpaid Only</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={isLoading}>
|
<ActionButton
|
||||||
|
icon={Download}
|
||||||
|
variant="secondary"
|
||||||
|
onClick={doExport}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
Export CSV
|
Export CSV
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
@@ -250,14 +329,14 @@ export default function SeatStatusReportPage() {
|
|||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
{[
|
{[
|
||||||
'Booking Ref',
|
"Booking Ref",
|
||||||
'Passenger',
|
"Passenger",
|
||||||
'Seat / Coach',
|
"Seat / Coach",
|
||||||
'Fare',
|
"Fare",
|
||||||
'Payment',
|
"Payment",
|
||||||
'Booked At',
|
"Booked At",
|
||||||
'Release At',
|
"Release At",
|
||||||
'Route',
|
"Route",
|
||||||
].map((h) => (
|
].map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
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">
|
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
{filtered.map((row, i) => {
|
{filtered.map((row, i) => {
|
||||||
const isPaid =
|
const isPaid =
|
||||||
row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED';
|
row.paymentStatus === "SUCCEEDED" ||
|
||||||
|
row.paymentStatus === "COMPLETED";
|
||||||
const expired = isExpired(row.releaseAt);
|
const expired = isExpired(row.releaseAt);
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
@@ -281,23 +361,31 @@ export default function SeatStatusReportPage() {
|
|||||||
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
|
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
|
||||||
{row.bookingRef}
|
{row.bookingRef}
|
||||||
</td>
|
</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">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
<span className="font-semibold">{row.seatNumber}</span>
|
<span className="font-semibold">{row.seatNumber}</span>
|
||||||
{row.coachNumber !== '—' && (
|
{row.coachNumber !== "—" && (
|
||||||
<span className="text-muted-foreground"> · Coach {row.coachNumber}</span>
|
<span className="text-muted-foreground">
|
||||||
|
{" "}
|
||||||
|
· Coach {row.coachNumber}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
{formatCurrency(row.fareMinor, row.currency)}
|
{formatCurrency(row.fareMinor, row.currency)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
<Badge variant="status" status={isPaid ? 'PAID' : row.paymentStatus}>
|
<Badge
|
||||||
{isPaid ? 'PAID' : row.paymentStatus}
|
variant="status"
|
||||||
|
status={isPaid ? "PAID" : row.paymentStatus}
|
||||||
|
>
|
||||||
|
{isPaid ? "PAID" : row.paymentStatus}
|
||||||
</Badge>
|
</Badge>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
|
<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>
|
||||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||||
{isPaid ? (
|
{isPaid ? (
|
||||||
@@ -308,13 +396,13 @@ export default function SeatStatusReportPage() {
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
expired
|
expired
|
||||||
? 'text-red-600 dark:text-red-400 text-xs font-semibold'
|
? "text-red-600 dark:text-red-400 text-xs font-semibold"
|
||||||
: 'text-amber-600 dark:text-amber-400 text-xs font-medium'
|
: "text-amber-600 dark:text-amber-400 text-xs font-medium"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{expired ? '⚠ ' : '⏱ '}
|
{expired ? "⚠ " : "⏱ "}
|
||||||
{formatDateTime(row.releaseAt)}
|
{formatDateTime(row.releaseAt)}
|
||||||
{expired && ' (expired)'}
|
{expired && " (expired)"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-xs">—</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">
|
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
|
||||||
{row.scheduleOrigin} → {row.scheduleDestination}
|
{row.scheduleOrigin} → {row.scheduleDestination}
|
||||||
{row.scheduleDeparture && (
|
{row.scheduleDeparture && (
|
||||||
<div className="text-xs">{formatDateTime(row.scheduleDeparture)}</div>
|
<div className="text-xs">
|
||||||
|
{formatDateTime(row.scheduleDeparture)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -10,6 +10,14 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
import { routesApi } from '@/lib/api/routes';
|
import { routesApi } from '@/lib/api/routes';
|
||||||
import { stationsApi, fleetApi, routeCoachTemplatesApi } from '@/lib/api';
|
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 {
|
interface RouteStop {
|
||||||
stationId: string;
|
stationId: string;
|
||||||
@@ -17,6 +25,8 @@ interface RouteStop {
|
|||||||
distanceKm?: number;
|
distanceKm?: number;
|
||||||
distanceFromOrigin?: number;
|
distanceFromOrigin?: number;
|
||||||
checkinMinutesBefore?: number;
|
checkinMinutesBefore?: number;
|
||||||
|
plannedArrivalTime?: string;
|
||||||
|
plannedDepartureTime?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'routes' | 'coaches';
|
type Tab = 'routes' | 'coaches';
|
||||||
@@ -175,6 +185,8 @@ export default function RoutesPage() {
|
|||||||
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
const [destinationDistance, setDestinationDistance] = useState<number | undefined>(undefined);
|
||||||
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
const [originCheckinMinutes, setOriginCheckinMinutes] = useState<number | undefined>(undefined);
|
||||||
const [destinationCheckinMinutes, setDestinationCheckinMinutes] = 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 [deleteConfirm, setDeleteConfirm] = useState<{ isOpen: boolean; route: any | null; error?: string; cascade?: boolean; cascadeChecked?: boolean }>({ isOpen: false, route: null });
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -245,18 +257,27 @@ export default function RoutesPage() {
|
|||||||
|
|
||||||
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
// distanceKm = cumulative distance from origin (fare engine uses destStop.distanceKm - originStop.distanceKm)
|
||||||
const stopsArray = [
|
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) => ({
|
...sortedMiddleStops.map((stop, idx) => ({
|
||||||
stationId: stop.stationId,
|
stationId: stop.stationId,
|
||||||
sequence: idx + 2,
|
sequence: idx + 2,
|
||||||
distanceKm: stop.distanceFromOrigin || 0,
|
distanceKm: stop.distanceFromOrigin || 0,
|
||||||
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
checkinMinutesBefore: stop.checkinMinutesBefore ?? undefined,
|
||||||
|
plannedArrivalTime: stop.plannedArrivalTime ? eatToISO(stop.plannedArrivalTime) : undefined,
|
||||||
|
plannedDepartureTime: stop.plannedDepartureTime ? eatToISO(stop.plannedDepartureTime) : undefined,
|
||||||
})),
|
})),
|
||||||
{
|
{
|
||||||
stationId: destinationStationId,
|
stationId: destinationStationId,
|
||||||
sequence: sortedMiddleStops.length + 2,
|
sequence: sortedMiddleStops.length + 2,
|
||||||
distanceKm: destinationDistance || 0,
|
distanceKm: destinationDistance || 0,
|
||||||
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
checkinMinutesBefore: destinationCheckinMinutes ?? undefined,
|
||||||
|
plannedArrivalTime: destinationArrivalTime ? eatToISO(destinationArrivalTime) : undefined,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -387,8 +408,10 @@ export default function RoutesPage() {
|
|||||||
const destStop = routeStops[routeStops.length - 1];
|
const destStop = routeStops[routeStops.length - 1];
|
||||||
setOriginStationId(originStop.stationId);
|
setOriginStationId(originStop.stationId);
|
||||||
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
|
setOriginCheckinMinutes(originStop.checkinMinutesBefore ?? undefined);
|
||||||
|
setOriginDepartureTime(originStop.plannedDepartureTime ? isoToEAT(originStop.plannedDepartureTime) : '');
|
||||||
setDestinationStationId(destStop.stationId);
|
setDestinationStationId(destStop.stationId);
|
||||||
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
setDestinationCheckinMinutes(destStop.checkinMinutesBefore ?? undefined);
|
||||||
|
setDestinationArrivalTime(destStop.plannedArrivalTime ? isoToEAT(destStop.plannedArrivalTime) : '');
|
||||||
setDestinationDistance(destStop.distanceKm || 0);
|
setDestinationDistance(destStop.distanceKm || 0);
|
||||||
setStops(routeStops.slice(1, -1).map((s: any) => ({
|
setStops(routeStops.slice(1, -1).map((s: any) => ({
|
||||||
stationId: s.stationId,
|
stationId: s.stationId,
|
||||||
@@ -396,6 +419,8 @@ export default function RoutesPage() {
|
|||||||
distanceKm: s.distanceKm,
|
distanceKm: s.distanceKm,
|
||||||
distanceFromOrigin: s.distanceKm || 0,
|
distanceFromOrigin: s.distanceKm || 0,
|
||||||
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
|
checkinMinutesBefore: s.checkinMinutesBefore ?? undefined,
|
||||||
|
plannedArrivalTime: s.plannedArrivalTime ? isoToEAT(s.plannedArrivalTime) : '',
|
||||||
|
plannedDepartureTime: s.plannedDepartureTime ? isoToEAT(s.plannedDepartureTime) : '',
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -433,8 +458,10 @@ export default function RoutesPage() {
|
|||||||
setEditingRoute(null);
|
setEditingRoute(null);
|
||||||
setOriginStationId('');
|
setOriginStationId('');
|
||||||
setOriginCheckinMinutes(undefined);
|
setOriginCheckinMinutes(undefined);
|
||||||
|
setOriginDepartureTime('');
|
||||||
setDestinationStationId('');
|
setDestinationStationId('');
|
||||||
setDestinationCheckinMinutes(undefined);
|
setDestinationCheckinMinutes(undefined);
|
||||||
|
setDestinationArrivalTime('');
|
||||||
setDestinationDistance(undefined);
|
setDestinationDistance(undefined);
|
||||||
setStops([]);
|
setStops([]);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
@@ -519,7 +546,7 @@ export default function RoutesPage() {
|
|||||||
setSearch('');
|
setSearch('');
|
||||||
}}
|
}}
|
||||||
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
title={`${editingRoute ? 'Edit' : 'Add'} Route`}
|
||||||
size="lg"
|
size="xl"
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
<form onSubmit={handleSubmit} className="space-y-4 max-h-[calc(100vh-200px)] overflow-y-auto">
|
||||||
{editingRoute && (
|
{editingRoute && (
|
||||||
@@ -665,7 +692,7 @@ export default function RoutesPage() {
|
|||||||
<div className="border-t pt-4">
|
<div className="border-t pt-4">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<label className="label mb-0">Route Stops</label>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -683,7 +710,7 @@ export default function RoutesPage() {
|
|||||||
<span className="text-muted-foreground">Select origin station above</span>
|
<span className="text-muted-foreground">Select origin station above</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28 flex-shrink-0">
|
<div className="w-24 flex-shrink-0">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-sm"
|
className="input input-sm"
|
||||||
@@ -694,7 +721,15 @@ export default function RoutesPage() {
|
|||||||
title="Check-in cutoff override (minutes) for this stop"
|
title="Check-in cutoff override (minutes) for this stop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{stops.map((stop, index) => (
|
{stops.map((stop, index) => (
|
||||||
@@ -729,7 +764,7 @@ export default function RoutesPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28">
|
<div className="w-20">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-sm"
|
className="input input-sm"
|
||||||
@@ -741,17 +776,33 @@ export default function RoutesPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28">
|
<div className="w-20">
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
className="input input-sm"
|
className="input input-sm"
|
||||||
placeholder="cutoff min"
|
placeholder="cutoff"
|
||||||
value={stop.checkinMinutesBefore ?? ''}
|
value={stop.checkinMinutesBefore ?? ''}
|
||||||
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
onChange={(e) => updateStop(index, 'checkinMinutesBefore', e.target.value ? parseInt(e.target.value) : undefined)}
|
||||||
min={1}
|
min={1}
|
||||||
title="Check-in cutoff override (minutes) for this stop"
|
title="Check-in cutoff override (minutes) for this stop"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeStop(index)}
|
onClick={() => removeStop(index)}
|
||||||
@@ -790,7 +841,7 @@ export default function RoutesPage() {
|
|||||||
<span className="text-muted-foreground">Select destination station above</span>
|
<span className="text-muted-foreground">Select destination station above</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="w-28">
|
<div className="w-24">
|
||||||
{destinationStationId && (
|
{destinationStationId && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -803,7 +854,18 @@ export default function RoutesPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 && (
|
{destinationStationId && (
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
|||||||
@@ -7,10 +7,29 @@ import DataTable from '@/components/ui/DataTable';
|
|||||||
import ActionButton from '@/components/ui/ActionButton';
|
import ActionButton from '@/components/ui/ActionButton';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
import ConfirmDialog from '@/components/ui/ConfirmDialog';
|
||||||
|
import DateTimePicker from '@/components/ui/DateTimePicker';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { routeCoachTemplatesApi } from '@/lib/api';
|
import { routeCoachTemplatesApi } from '@/lib/api';
|
||||||
import { formatDateTime } from '@/lib/utils';
|
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 {
|
interface Schedule {
|
||||||
id: string;
|
id: string;
|
||||||
trainId: string;
|
trainId: string;
|
||||||
@@ -24,6 +43,13 @@ interface Schedule {
|
|||||||
destinationStation?: { id: string; name: string };
|
destinationStation?: { id: string; name: string };
|
||||||
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
coachAssignments?: Array<{ coachId: string; positionNumber: number; coach?: { id: string; number: string } }>;
|
||||||
isPackageOnly?: boolean;
|
isPackageOnly?: boolean;
|
||||||
|
stopTimes?: Array<{
|
||||||
|
sequence: number;
|
||||||
|
stationId: string;
|
||||||
|
plannedDepartureAt: string | null;
|
||||||
|
plannedArrivalAt: string | null;
|
||||||
|
station?: { name: string };
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Train {
|
interface Train {
|
||||||
@@ -72,6 +98,8 @@ export default function SchedulesPage() {
|
|||||||
|
|
||||||
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
const [addForm, setAddForm] = useState({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||||
const [addCoachRows, setAddCoachRows] = useState<{ coachId: string; positionNumber: number }[]>([]);
|
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({
|
const { data: singleRouteTemplate, isLoading: singleTemplateLoading } = useQuery({
|
||||||
queryKey: ['route-coaches', addForm.routeId],
|
queryKey: ['route-coaches', addForm.routeId],
|
||||||
@@ -79,12 +107,42 @@ export default function SchedulesPage() {
|
|||||||
enabled: !!addForm.routeId,
|
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(() => {
|
useEffect(() => {
|
||||||
if (!addForm.routeId) { setAddCoachRows([]); return; }
|
if (!addForm.routeId) { setAddCoachRows([]); return; }
|
||||||
const rows: any[] = Array.isArray(singleRouteTemplate) ? singleRouteTemplate : (singleRouteTemplate as any)?.coaches ?? [];
|
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 })) : []);
|
setAddCoachRows(rows.length ? rows.map((r: any) => ({ coachId: r.coachId ?? r.coach?.id, positionNumber: r.positionNumber })) : []);
|
||||||
}, [singleRouteTemplate, addForm.routeId]);
|
}, [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
|
// Fetch route coach template when route changes
|
||||||
const { data: routeTemplate, isLoading: templateLoading } = useQuery({
|
const { data: routeTemplate, isLoading: templateLoading } = useQuery({
|
||||||
queryKey: ['route-coaches', bulkForm.routeId],
|
queryKey: ['route-coaches', bulkForm.routeId],
|
||||||
@@ -110,6 +168,24 @@ export default function SchedulesPage() {
|
|||||||
isPackageOnly: false,
|
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({
|
const [filters, setFilters] = useState({
|
||||||
search: '',
|
search: '',
|
||||||
trainId: '',
|
trainId: '',
|
||||||
@@ -175,6 +251,7 @@ export default function SchedulesPage() {
|
|||||||
setShowAddModal(false);
|
setShowAddModal(false);
|
||||||
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
setAddForm({ trainId: '', routeId: '', departureAt: '', arrivalAt: '' });
|
||||||
setAddCoachRows([]);
|
setAddCoachRows([]);
|
||||||
|
setAddStopTimes([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -189,6 +266,7 @@ export default function SchedulesPage() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
queryClient.invalidateQueries({ queryKey: ['schedules'] });
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingSchedule(null);
|
setEditingSchedule(null);
|
||||||
|
setEditStopTimes([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
},
|
},
|
||||||
onError: (err: any) => {
|
onError: (err: any) => {
|
||||||
@@ -255,15 +333,30 @@ export default function SchedulesPage() {
|
|||||||
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
const handleAddSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
const dep = new Date(addForm.departureAt);
|
if (!addForm.departureAt || !addForm.arrivalAt) {
|
||||||
const arr = new Date(addForm.arrivalAt);
|
setError('Please select departure and arrival date & time');
|
||||||
if (arr <= dep) { setError('Arrival must be after departure'); return; }
|
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);
|
const validCoaches = addCoachRows.filter((r) => r.coachId);
|
||||||
await createScheduleMutation.mutateAsync({
|
await createScheduleMutation.mutateAsync({
|
||||||
trainId: addForm.trainId,
|
trainId: addForm.trainId,
|
||||||
routeId: addForm.routeId,
|
routeId: addForm.routeId,
|
||||||
departureAt: dep.toISOString(),
|
departureAt: eatToISO(addForm.departureAt),
|
||||||
arrivalAt: arr.toISOString(),
|
arrivalAt: eatToISO(addForm.arrivalAt),
|
||||||
|
...(plannedTimes ? { plannedTimes } : {}),
|
||||||
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
...(validCoaches.length > 0 && { coachIds: validCoaches.map((r) => r.coachId) }),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -274,24 +367,35 @@ export default function SchedulesPage() {
|
|||||||
|
|
||||||
if (!editingSchedule) return;
|
if (!editingSchedule) return;
|
||||||
|
|
||||||
// Convert local datetime-local values to UTC for API
|
if (!editForm.departureAt || !editForm.arrivalAt) {
|
||||||
const depLocal = new Date(editForm.departureAt);
|
setError('Please select departure and arrival date & time');
|
||||||
const arrLocal = new Date(editForm.arrivalAt);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (arrLocal <= depLocal) {
|
if (new Date(editForm.arrivalAt + ':00Z') <= new Date(editForm.departureAt + ':00Z')) {
|
||||||
setError('Arrival time must be after departure time');
|
setError('Arrival time must be after departure time');
|
||||||
return;
|
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 = {
|
const payload: any = {
|
||||||
departureAt: depLocal.toISOString(),
|
departureAt: eatToISO(editForm.departureAt),
|
||||||
arrivalAt: arrLocal.toISOString(),
|
arrivalAt: eatToISO(editForm.arrivalAt),
|
||||||
status: editForm.status,
|
status: editForm.status,
|
||||||
isPackageOnly: editForm.isPackageOnly,
|
isPackageOnly: editForm.isPackageOnly,
|
||||||
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
coaches: editForm.coachIds.map((coachId: string, idx: number) => ({
|
||||||
coachId,
|
coachId,
|
||||||
positionNumber: idx + 1,
|
positionNumber: idx + 1,
|
||||||
})),
|
})),
|
||||||
|
...(editPlannedTimes ? { plannedTimes: editPlannedTimes } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await updateScheduleMutation.mutateAsync({
|
await updateScheduleMutation.mutateAsync({
|
||||||
@@ -327,26 +431,26 @@ export default function SchedulesPage() {
|
|||||||
const handleEditClick = (schedule: Schedule) => {
|
const handleEditClick = (schedule: Schedule) => {
|
||||||
setEditingSchedule(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({
|
setEditForm({
|
||||||
departureAt: depStr,
|
departureAt: isoToEAT(schedule.departureAt),
|
||||||
arrivalAt: arrStr,
|
arrivalAt: isoToEAT(schedule.arrivalAt),
|
||||||
status: schedule.status,
|
status: schedule.status,
|
||||||
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
|
coachIds: schedule.coachAssignments?.map((ca: any) => ca.coachId) || [],
|
||||||
isPackageOnly: schedule.isPackageOnly ?? false,
|
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);
|
setError(null);
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
@@ -672,7 +776,7 @@ export default function SchedulesPage() {
|
|||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={showAddModal}
|
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"
|
title="Add Schedule"
|
||||||
size="lg"
|
size="lg"
|
||||||
>
|
>
|
||||||
@@ -699,14 +803,114 @@ export default function SchedulesPage() {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Departure *</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Arrival *</label>
|
<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>
|
||||||
</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="border-t pt-4">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="label mb-0">Coaches</label>
|
<label className="label mb-0">Coaches</label>
|
||||||
@@ -1006,6 +1210,7 @@ export default function SchedulesPage() {
|
|||||||
onClose={() => {
|
onClose={() => {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingSchedule(null);
|
setEditingSchedule(null);
|
||||||
|
setEditStopTimes([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
title={`Edit Schedule: ${editingSchedule?.originStation?.name ?? ''} → ${editingSchedule?.destinationStation?.name ?? ''}`}
|
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 className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Departure Date & Time *</label>
|
<label className="label">Departure Date & Time *</label>
|
||||||
<input
|
<DateTimePicker
|
||||||
type="datetime-local"
|
|
||||||
value={editForm.departureAt}
|
value={editForm.departureAt}
|
||||||
onChange={(e) => setEditForm({ ...editForm, departureAt: e.target.value })}
|
onChange={(v) => setEditForm({ ...editForm, departureAt: v })}
|
||||||
className="input"
|
placeholder="Select departure"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Arrival Date & Time *</label>
|
<label className="label">Arrival Date & Time *</label>
|
||||||
<input
|
<DateTimePicker
|
||||||
type="datetime-local"
|
|
||||||
value={editForm.arrivalAt}
|
value={editForm.arrivalAt}
|
||||||
onChange={(e) => setEditForm({ ...editForm, arrivalAt: e.target.value })}
|
onChange={(v) => setEditForm({ ...editForm, arrivalAt: v })}
|
||||||
className="input"
|
placeholder="Select arrival"
|
||||||
required
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1072,6 +1273,101 @@ export default function SchedulesPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<label className="label">Coaches (Optional)</label>
|
<label className="label">Coaches (Optional)</label>
|
||||||
@@ -1129,6 +1425,7 @@ export default function SchedulesPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditingSchedule(null);
|
setEditingSchedule(null);
|
||||||
|
setEditStopTimes([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -80,13 +80,13 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
title: 'Master Data',
|
title: 'Master Data',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Stations', href: '/stations', icon: MapPin, permission: PERMS.stations.view },
|
{ name: 'Stations', href: '/stations', icon: MapPin },
|
||||||
{ name: 'Trains', href: '/trains', icon: Train, permission: PERMS.trains.view },
|
{ name: 'Trains', href: '/trains', icon: Train },
|
||||||
{ name: 'Coaches', href: '/coaches', icon: Grid3x3, permission: PERMS.coaches.view },
|
{ name: 'Coaches', href: '/coaches', icon: Grid3x3 },
|
||||||
{ name: 'Seats', href: '/seats', icon: Armchair, permission: PERMS.seats.view },
|
{ name: 'Seats', href: '/seats', icon: Armchair },
|
||||||
{ name: 'Classes', href: '/classes', icon: Settings, permission: PERMS.classes.view },
|
{ name: 'Classes', href: '/classes', icon: Settings },
|
||||||
{ name: 'Routes', href: '/routes', icon: Route, permission: PERMS.routes.view },
|
{ name: 'Routes', href: '/routes', icon: Route },
|
||||||
{ name: 'Schedules', href: '/schedules', icon: Calendar, permission: PERMS.schedules.view },
|
{ name: 'Schedules', href: '/schedules', icon: Calendar },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
11
pnpm-lock.yaml
generated
@@ -953,6 +953,9 @@ importers:
|
|||||||
react:
|
react:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1
|
version: 18.3.1
|
||||||
|
react-day-picker:
|
||||||
|
specifier: ^9.14.0
|
||||||
|
version: 9.14.0(react@18.3.1)
|
||||||
react-dom:
|
react-dom:
|
||||||
specifier: ^18.3.1
|
specifier: ^18.3.1
|
||||||
version: 18.3.1(react@18.3.1)
|
version: 18.3.1(react@18.3.1)
|
||||||
@@ -22123,6 +22126,14 @@ snapshots:
|
|||||||
date-fns: 3.6.0
|
date-fns: 3.6.0
|
||||||
react: 19.2.6
|
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):
|
react-day-picker@9.14.0(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@date-fns/tz': 1.5.0
|
'@date-fns/tz': 1.5.0
|
||||||
|
|||||||
Reference in New Issue
Block a user