mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Merge branch 'main' of https://github.com/Tria-plc/edr-platform into alpha
This commit is contained in:
@@ -13,8 +13,8 @@ export class DashboardService {
|
||||
async getBackofficeStats() {
|
||||
const [totalBookings, totalPackageBookings, totalTickets, totalPassengers, blockedSeatsCount, revenueRows, packageRevenueRows] =
|
||||
await Promise.all([
|
||||
this.prisma.booking.count(),
|
||||
this.prisma.booking.count({ where: { packageId: { not: null } } }),
|
||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] } } }),
|
||||
this.prisma.booking.count({ where: { status: { in: ['CONFIRMED', 'BOARDED'] }, packageId: { not: null } } }),
|
||||
this.prisma.ticket.count(),
|
||||
this.prisma.passenger.count(),
|
||||
this.prisma.seat.count({ where: { status: 'BLOCKED' } }),
|
||||
|
||||
@@ -1,38 +1,50 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
|
||||
import { ReportsService } from './reports.service';
|
||||
import { GenerateReportDto } from './reports.dto';
|
||||
import { PassengerStaff } from '../../common/passenger-guards';
|
||||
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
|
||||
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
|
||||
import { ReportsService } from "./reports.service";
|
||||
import { GenerateReportDto } from "./reports.dto";
|
||||
import { PassengerStaff } from "../../common/passenger-guards";
|
||||
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
|
||||
@ApiTags('Reports')
|
||||
@Controller('reports')
|
||||
@ApiTags("Reports")
|
||||
@Controller("reports")
|
||||
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiBearerAuth("IAM-auth")
|
||||
export class ReportsController {
|
||||
constructor(private service: ReportsService) {}
|
||||
|
||||
@Post('generate')
|
||||
@ApiOperation({ summary: 'Generate operational report' })
|
||||
@Post("generate")
|
||||
@ApiOperation({ summary: "Generate operational report" })
|
||||
generateReport(@Body() dto: GenerateReportDto) {
|
||||
return this.service.generateReport(dto);
|
||||
}
|
||||
|
||||
@Get('passengers')
|
||||
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
|
||||
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||
@Get("schedules")
|
||||
@ApiOperation({ summary: "List schedules for the passengers report picker" })
|
||||
listSchedulesForPicker() {
|
||||
return this.service.listSchedulesForPicker();
|
||||
}
|
||||
|
||||
@Get("passengers/list")
|
||||
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
|
||||
getPassengerList(@Query("scheduleId") scheduleId: string) {
|
||||
return this.service.getPassengerList(scheduleId);
|
||||
}
|
||||
|
||||
@Get("passengers")
|
||||
@ApiOperation({ summary: "Passengers report for a specific schedule" })
|
||||
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
|
||||
return this.service.getOccupancyBySchedule(scheduleId);
|
||||
}
|
||||
|
||||
@Get(':reportId')
|
||||
@ApiOperation({ summary: 'Get report by ID' })
|
||||
getReport(@Param('reportId') reportId: string) {
|
||||
@Get(":reportId")
|
||||
@ApiOperation({ summary: "Get report by ID" })
|
||||
getReport(@Param("reportId") reportId: string) {
|
||||
return this.service.getReport(reportId);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List reports' })
|
||||
listReports(@Query('type') type?: string) {
|
||||
@ApiOperation({ summary: "List reports" })
|
||||
listReports(@Query("type") type?: string) {
|
||||
return this.service.listReports(type);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectDataSource } from '@nestjs/typeorm';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { PrismaService } from '../../common/prisma.service';
|
||||
import { GenerateReportDto, ReportType } from './reports.dto';
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import { InjectDataSource } from "@nestjs/typeorm";
|
||||
import { DataSource } from "typeorm";
|
||||
import { PrismaService } from "../../common/prisma.service";
|
||||
import { GenerateReportDto, ReportType } from "./reports.dto";
|
||||
|
||||
@Injectable()
|
||||
export class ReportsService {
|
||||
@@ -15,7 +15,7 @@ export class ReportsService {
|
||||
async generateReport(dto: GenerateReportDto) {
|
||||
const dateFrom = new Date(dto.dateFrom);
|
||||
dateFrom.setHours(0, 0, 0, 0);
|
||||
|
||||
|
||||
const dateTo = new Date(dto.dateTo);
|
||||
dateTo.setHours(23, 59, 59, 999);
|
||||
|
||||
@@ -28,7 +28,11 @@ export class ReportsService {
|
||||
data = await this.generateOccupancyReport(dateFrom, dateTo);
|
||||
break;
|
||||
case ReportType.AGENT_SALES:
|
||||
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
|
||||
data = await this.generateAgentSalesReport(
|
||||
dateFrom,
|
||||
dateTo,
|
||||
dto.agentId,
|
||||
);
|
||||
break;
|
||||
case ReportType.CANCELLATIONS:
|
||||
data = await this.generateCancellationsReport(dateFrom, dateTo);
|
||||
@@ -45,8 +49,8 @@ export class ReportsService {
|
||||
reportType: dto.reportType,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
data
|
||||
}
|
||||
data,
|
||||
},
|
||||
});
|
||||
|
||||
return { reportId: report.id, reportType: dto.reportType, data };
|
||||
@@ -56,37 +60,43 @@ export class ReportsService {
|
||||
// Fetch all bookings in date range, regardless of status
|
||||
const bookings = await this.prisma.booking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo }
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
},
|
||||
include: { paymentIntent: true }
|
||||
include: { paymentIntent: true },
|
||||
});
|
||||
|
||||
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
|
||||
const byPaymentMethod = bookings.reduce((acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? 'UNKNOWN';
|
||||
acc[method] = (acc[method] || 0) + b.totalMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
const byPaymentMethod = bookings.reduce(
|
||||
(acc, b) => {
|
||||
const method = b.paymentIntent?.method ?? "UNKNOWN";
|
||||
acc[method] = (acc[method] || 0) + b.totalMinor;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
// Group by date for charts
|
||||
const byDate = bookings.reduce((acc, b) => {
|
||||
const date = b.createdAt.toISOString().split('T')[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
const byDate = bookings.reduce(
|
||||
(acc, b) => {
|
||||
const date = b.createdAt.toISOString().split("T")[0];
|
||||
if (!acc[date]) {
|
||||
acc[date] = { totalMinor: 0, count: 0 };
|
||||
}
|
||||
acc[date].totalMinor += b.totalMinor;
|
||||
acc[date].count += 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalBookings: bookings.length,
|
||||
totalRevenueMinor: totalRevenue,
|
||||
totalRevenue: totalRevenue / 100,
|
||||
currency: 'ETB',
|
||||
currency: "ETB",
|
||||
byPaymentMethod,
|
||||
byDate,
|
||||
cancellationRate: 0
|
||||
cancellationRate: 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -95,75 +105,116 @@ export class ReportsService {
|
||||
where: { departureAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: {
|
||||
coachAssignments: { include: { coach: { include: { seats: true } } } },
|
||||
bookings: { include: { seats: true } },
|
||||
bookings: {
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
include: { seats: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const tripData = schedules.map(schedule => {
|
||||
const totalSeats = schedule.coachAssignments.reduce((sum, a) => sum + a.coach.seats.length, 0);
|
||||
const bookedSeats = schedule.bookings.reduce((sum, b) => sum + b.seats.length, 0);
|
||||
const occupancyRate = totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
||||
return { scheduleId: schedule.id, departureAt: schedule.departureAt, totalSeats, bookedSeats, occupancyRate: +occupancyRate.toFixed(2) };
|
||||
const tripData = schedules.map((schedule) => {
|
||||
const totalSeats = schedule.coachAssignments.reduce(
|
||||
(sum, a) => sum + a.coach.seats.length,
|
||||
0,
|
||||
);
|
||||
const bookedSeats = schedule.bookings.reduce(
|
||||
(sum, b) =>
|
||||
sum + b.seats.filter((s: any) => s.scheduleId === schedule.id).length,
|
||||
0,
|
||||
);
|
||||
const occupancyRate =
|
||||
totalSeats > 0 ? (bookedSeats / totalSeats) * 100 : 0;
|
||||
return {
|
||||
scheduleId: schedule.id,
|
||||
departureAt: schedule.departureAt,
|
||||
totalSeats,
|
||||
bookedSeats,
|
||||
occupancyRate: +occupancyRate.toFixed(2),
|
||||
};
|
||||
});
|
||||
|
||||
const avgOccupancy = tripData.length > 0 ? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) / tripData.length : 0;
|
||||
return { totalSchedules: schedules.length, averageOccupancyRate: +avgOccupancy.toFixed(2), schedules: tripData };
|
||||
const avgOccupancy =
|
||||
tripData.length > 0
|
||||
? tripData.reduce((sum, t) => sum + t.occupancyRate, 0) /
|
||||
tripData.length
|
||||
: 0;
|
||||
return {
|
||||
totalSchedules: schedules.length,
|
||||
averageOccupancyRate: +avgOccupancy.toFixed(2),
|
||||
schedules: tripData,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateAgentSalesReport(dateFrom: Date, dateTo: Date, agentId?: string) {
|
||||
private async generateAgentSalesReport(
|
||||
dateFrom: Date,
|
||||
dateTo: Date,
|
||||
agentId?: string,
|
||||
) {
|
||||
const agentBookings = await this.prisma.agentBooking.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
...(agentId ? { agentId } : {})
|
||||
...(agentId ? { agentId } : {}),
|
||||
},
|
||||
include: {
|
||||
agent: { select: { id: true, iamUserId: true, agentCode: true } },
|
||||
booking: true
|
||||
}
|
||||
booking: true,
|
||||
},
|
||||
});
|
||||
|
||||
const iamUserIds = [...new Set(
|
||||
agentBookings.map(ab => ab.agent.iamUserId).filter(Boolean) as string[]
|
||||
)];
|
||||
const iamRows = iamUserIds.length > 0
|
||||
? await this.dataSource.query<{ id: string; name: { en?: string; am?: string } | null }[]>(
|
||||
`SELECT id, name FROM iam.users WHERE id = ANY($1)`,
|
||||
[iamUserIds],
|
||||
)
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map(r => [r.id, r]));
|
||||
const iamUserIds = [
|
||||
...new Set(
|
||||
agentBookings
|
||||
.map((ab) => ab.agent.iamUserId)
|
||||
.filter(Boolean) as string[],
|
||||
),
|
||||
];
|
||||
const iamRows =
|
||||
iamUserIds.length > 0
|
||||
? await this.dataSource.query<
|
||||
{ id: string; name: { en?: string; am?: string } | null }[]
|
||||
>(`SELECT id, name FROM iam.users WHERE id = ANY($1)`, [iamUserIds])
|
||||
: [];
|
||||
const iamMap = new Map(iamRows.map((r) => [r.id, r]));
|
||||
|
||||
const byAgent = agentBookings.reduce((acc, ab) => {
|
||||
const iam = ab.agent.iamUserId ? iamMap.get(ab.agent.iamUserId) : undefined;
|
||||
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
||||
if (!acc[agentName]) {
|
||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||
}
|
||||
acc[agentName].bookings += 1;
|
||||
acc[agentName].revenueMinor += ab.booking.totalMinor;
|
||||
acc[agentName].cashCollected += ab.cashReceived ?? 0;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
const byAgent = agentBookings.reduce(
|
||||
(acc, ab) => {
|
||||
const iam = ab.agent.iamUserId
|
||||
? iamMap.get(ab.agent.iamUserId)
|
||||
: undefined;
|
||||
const agentName = iam?.name?.en ?? iam?.name?.am ?? ab.agent.agentCode;
|
||||
if (!acc[agentName]) {
|
||||
acc[agentName] = { bookings: 0, revenueMinor: 0, cashCollected: 0 };
|
||||
}
|
||||
acc[agentName].bookings += 1;
|
||||
acc[agentName].revenueMinor += ab.booking.totalMinor;
|
||||
acc[agentName].cashCollected += ab.cashReceived ?? 0;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalAgentBookings: agentBookings.length,
|
||||
byAgent
|
||||
byAgent,
|
||||
};
|
||||
}
|
||||
|
||||
private async generateCancellationsReport(dateFrom: Date, dateTo: Date) {
|
||||
const cancellations = await this.prisma.bookingCancellation.findMany({
|
||||
where: { createdAt: { gte: dateFrom, lte: dateTo } },
|
||||
include: { booking: true }
|
||||
include: { booking: true },
|
||||
});
|
||||
|
||||
const totalRefunded = cancellations.reduce((sum, c) => sum + c.refundAmount, 0);
|
||||
const totalRefunded = cancellations.reduce(
|
||||
(sum, c) => sum + c.refundAmount,
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
totalCancellations: cancellations.length,
|
||||
totalRefundedMinor: totalRefunded,
|
||||
totalRefunded: totalRefunded / 100,
|
||||
currency: 'ETB'
|
||||
currency: "ETB",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,23 +222,26 @@ export class ReportsService {
|
||||
const payments = await this.prisma.paymentIntent.findMany({
|
||||
where: {
|
||||
createdAt: { gte: dateFrom, lte: dateTo },
|
||||
status: 'SUCCEEDED'
|
||||
}
|
||||
status: "SUCCEEDED",
|
||||
},
|
||||
});
|
||||
|
||||
const byMethod = payments.reduce((acc, p) => {
|
||||
const method = p.method;
|
||||
if (!acc[method]) {
|
||||
acc[method] = { count: 0, totalMinor: 0 };
|
||||
}
|
||||
acc[method].count += 1;
|
||||
acc[method].totalMinor += p.amountMinor;
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
const byMethod = payments.reduce(
|
||||
(acc, p) => {
|
||||
const method = p.method;
|
||||
if (!acc[method]) {
|
||||
acc[method] = { count: 0, totalMinor: 0 };
|
||||
}
|
||||
acc[method].count += 1;
|
||||
acc[method].totalMinor += p.amountMinor;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
);
|
||||
|
||||
return {
|
||||
totalPayments: payments.length,
|
||||
byMethod
|
||||
byMethod,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,33 +263,48 @@ export class ReportsService {
|
||||
},
|
||||
},
|
||||
bookings: {
|
||||
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||
where: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
include: {
|
||||
seats: {
|
||||
where: { scheduleId },
|
||||
include: {
|
||||
seat: { include: { coach: { include: { coachType: true } } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: 'asc' } },
|
||||
stopTimes: { include: { station: true }, orderBy: { sequence: "asc" } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!schedule) return null;
|
||||
|
||||
const totalSeats = (schedule as any).coachAssignments.reduce((s: number, a: any) => s + a.coach.seats.length, 0);
|
||||
const allBookingSeats = (schedule as any).bookings.flatMap((b: any) => b.seats);
|
||||
const totalSeats = (schedule as any).coachAssignments.reduce(
|
||||
(s: number, a: any) => s + a.coach.seats.length,
|
||||
0,
|
||||
);
|
||||
const allBookingSeats = (schedule as any).bookings.flatMap(
|
||||
(b: any) => b.seats,
|
||||
);
|
||||
const totalPassengers = allBookingSeats.length;
|
||||
const occupancyRate = totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
const occupancyRate =
|
||||
totalSeats > 0 ? +((totalPassengers / totalSeats) * 100).toFixed(1) : 0;
|
||||
|
||||
// Per-coach breakdown
|
||||
const coachMap = new Map<string, { coachNumber: string; coachType: string; totalSeats: number; booked: number }>();
|
||||
const coachMap = new Map<
|
||||
string,
|
||||
{
|
||||
coachNumber: string;
|
||||
coachType: string;
|
||||
totalSeats: number;
|
||||
booked: number;
|
||||
}
|
||||
>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const c = assignment.coach;
|
||||
coachMap.set(c.id, {
|
||||
coachNumber: c.number,
|
||||
coachType: (c as any).coachType?.name ?? 'Unknown',
|
||||
coachType: (c as any).coachType?.name ?? "Unknown",
|
||||
totalSeats: c.seats.length,
|
||||
booked: 0,
|
||||
});
|
||||
@@ -244,56 +313,91 @@ export class ReportsService {
|
||||
const coachId = bs.seat?.coachId;
|
||||
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||
}
|
||||
const byCoach = [...coachMap.values()].map(c => ({
|
||||
const byCoach = [...coachMap.values()].map((c) => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
occupancyRate:
|
||||
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
// Per-origin station breakdown (using booking's originStationId)
|
||||
const originMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
const originMap = new Map<
|
||||
string,
|
||||
{ stationName: string; passengers: number }
|
||||
>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.originStationId ?? schedule.originStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).originStation?.name
|
||||
?? stationId;
|
||||
if (!originMap.has(stationId)) originMap.set(stationId, { stationName, passengers: 0 });
|
||||
const stationName =
|
||||
(schedule as any).stopTimes.find(
|
||||
(st: any) => st.stationId === stationId,
|
||||
)?.station?.name ??
|
||||
(schedule as any).originStation?.name ??
|
||||
stationId;
|
||||
if (!originMap.has(stationId))
|
||||
originMap.set(stationId, { stationName, passengers: 0 });
|
||||
originMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
const byOrigin = [...originMap.values()].sort(
|
||||
(a, b) => b.passengers - a.passengers,
|
||||
);
|
||||
|
||||
// Per-destination station breakdown
|
||||
const destMap = new Map<string, { stationName: string; passengers: number }>();
|
||||
const destMap = new Map<
|
||||
string,
|
||||
{ stationName: string; passengers: number }
|
||||
>();
|
||||
for (const booking of (schedule as any).bookings) {
|
||||
const stationId = booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const stationName = (schedule as any).stopTimes.find((st: any) => st.stationId === stationId)?.station?.name
|
||||
?? (schedule as any).destinationStation?.name
|
||||
?? stationId;
|
||||
if (!destMap.has(stationId)) destMap.set(stationId, { stationName, passengers: 0 });
|
||||
const stationId =
|
||||
booking.destinationStationId ?? schedule.destinationStationId;
|
||||
const stationName =
|
||||
(schedule as any).stopTimes.find(
|
||||
(st: any) => st.stationId === stationId,
|
||||
)?.station?.name ??
|
||||
(schedule as any).destinationStation?.name ??
|
||||
stationId;
|
||||
if (!destMap.has(stationId))
|
||||
destMap.set(stationId, { stationName, passengers: 0 });
|
||||
destMap.get(stationId)!.passengers += booking.seats.length;
|
||||
}
|
||||
const byDestination = [...destMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||
const byDestination = [...destMap.values()].sort(
|
||||
(a, b) => b.passengers - a.passengers,
|
||||
);
|
||||
|
||||
// Per-class breakdown
|
||||
const classMap = new Map<string, { className: string; totalSeats: number; booked: number }>();
|
||||
const classMap = new Map<
|
||||
string,
|
||||
{ className: string; totalSeats: number; booked: number }
|
||||
>();
|
||||
for (const assignment of (schedule as any).coachAssignments) {
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
const typeName = (assignment.coach as any).coachType?.name ?? "Unknown";
|
||||
if (!classMap.has(typeName))
|
||||
classMap.set(typeName, {
|
||||
className: typeName,
|
||||
totalSeats: 0,
|
||||
booked: 0,
|
||||
});
|
||||
classMap.get(typeName)!.totalSeats += assignment.coach.seats.length;
|
||||
}
|
||||
for (const bs of allBookingSeats) {
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? 'Unknown';
|
||||
if (!classMap.has(typeName)) classMap.set(typeName, { className: typeName, totalSeats: 0, booked: 0 });
|
||||
const typeName = bs.seat?.coach?.coachType?.name ?? "Unknown";
|
||||
if (!classMap.has(typeName))
|
||||
classMap.set(typeName, {
|
||||
className: typeName,
|
||||
totalSeats: 0,
|
||||
booked: 0,
|
||||
});
|
||||
classMap.get(typeName)!.booked++;
|
||||
}
|
||||
const byClass = [...classMap.values()].map(c => ({
|
||||
const byClass = [...classMap.values()].map((c) => ({
|
||||
...c,
|
||||
occupancyRate: c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
occupancyRate:
|
||||
c.totalSeats > 0 ? +((c.booked / c.totalSeats) * 100).toFixed(1) : 0,
|
||||
}));
|
||||
|
||||
return {
|
||||
schedule: {
|
||||
id: schedule.id,
|
||||
trainName: (schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
trainName:
|
||||
(schedule as any).train?.name ?? (schedule as any).train?.number,
|
||||
origin: (schedule as any).originStation?.name,
|
||||
destination: (schedule as any).destinationStation?.name,
|
||||
departureAt: schedule.departureAt,
|
||||
@@ -307,15 +411,68 @@ export class ReportsService {
|
||||
};
|
||||
}
|
||||
|
||||
async listSchedulesForPicker() {
|
||||
const schedules = await this.prisma.trainSchedule.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
departureAt: true,
|
||||
train: { select: { number: true } },
|
||||
originStation: { select: { name: true } },
|
||||
destinationStation: { select: { name: true } },
|
||||
},
|
||||
orderBy: { departureAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
return schedules.map((s) => ({
|
||||
id: s.id,
|
||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async getPassengerList(scheduleId: string) {
|
||||
const seats = await this.prisma.bookingSeat.findMany({
|
||||
where: {
|
||||
scheduleId,
|
||||
booking: { status: { in: ["CONFIRMED", "BOARDED"] } },
|
||||
},
|
||||
include: {
|
||||
booking: { select: { bookingRef: true, status: true } },
|
||||
seat: {
|
||||
include: {
|
||||
coach: {
|
||||
select: { number: true, coachType: { select: { name: true } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ seat: { coach: { number: "asc" } } }],
|
||||
});
|
||||
return seats.map((bs) => ({
|
||||
bookingRef: bs.booking.bookingRef,
|
||||
bookingStatus: bs.booking.status,
|
||||
passengerName: bs.passengerName,
|
||||
passengerCategory: bs.passengerCategory,
|
||||
idDocumentType: bs.idDocumentType,
|
||||
idDocumentNumber: bs.idDocumentNumber,
|
||||
passportNumber: bs.passportNumber,
|
||||
passportCountry: bs.passportCountry,
|
||||
seatLabel: bs.seatLabelSnapshot,
|
||||
coachNumber: bs.seat?.coach?.number ?? null,
|
||||
coachType: (bs.seat?.coach as any)?.coachType?.name ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async getReport(reportId: string) {
|
||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||
return this.prisma.operationalReport.findUnique({
|
||||
where: { id: reportId },
|
||||
});
|
||||
}
|
||||
|
||||
async listReports(reportType?: string) {
|
||||
return this.prisma.operationalReport.findMany({
|
||||
where: reportType ? { reportType } : {},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 50,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,16 @@ import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
|
||||
export class SeatsController {
|
||||
constructor(private service: SeatsService) {}
|
||||
|
||||
// ── Blocked Seats ─────────────────────────────────────────────────────────
|
||||
@Get('blocks')
|
||||
@PassengerStaff([PASSENGER_PERMS.seats.manage, PASSENGER_PERMS.admin])
|
||||
@ApiBearerAuth('IAM-auth')
|
||||
@ApiOperation({ summary: 'List all blocked seats with reason and coach info' })
|
||||
@ApiResponse({ status: 200, description: 'Blocked seat records' })
|
||||
getBlockedSeats() {
|
||||
return this.service.getBlockedSeats();
|
||||
}
|
||||
|
||||
// ── Coach Availability ────────────────────────────────────────────────────
|
||||
@Get('coaches/:scheduleId')
|
||||
@SetMetadata('isPublic', true)
|
||||
|
||||
@@ -615,6 +615,26 @@ export class SeatsService {
|
||||
await this.prisma.journey.deleteMany({ where: { bookingId } as any });
|
||||
}
|
||||
|
||||
async getBlockedSeats() {
|
||||
const blocks = await this.prisma.seatBlock.findMany({
|
||||
include: {
|
||||
seat: { include: { coach: { select: { number: true } } } },
|
||||
},
|
||||
orderBy: { blockedAt: 'desc' },
|
||||
});
|
||||
return blocks.map(b => ({
|
||||
id: b.id,
|
||||
seatId: b.seatId,
|
||||
seatNumber: b.seat.seatNumber,
|
||||
coachNumber: b.seat.coach.number,
|
||||
scheduleId: b.scheduleId,
|
||||
reason: b.reason,
|
||||
blockedBy: b.blockedBy,
|
||||
blockedAt: b.blockedAt,
|
||||
unblockAt: b.unblockAt,
|
||||
}));
|
||||
}
|
||||
|
||||
async getCoachesWithAvailability(scheduleId: string, originStationId?: string, destinationStationId?: string) {
|
||||
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||
where: { id: scheduleId },
|
||||
|
||||
@@ -1,48 +1,77 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { PermissionGuard } from '@/components/layout/PermissionGuard';
|
||||
import { PERMS } from '@/lib/permissions';
|
||||
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatCurrency } from '@/lib/utils';
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import Link from 'next/link';
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { PermissionGuard } from "@/components/layout/PermissionGuard";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import {
|
||||
Ticket,
|
||||
AlertCircle,
|
||||
BookOpen,
|
||||
Banknote,
|
||||
ArrowRight,
|
||||
ScanLine,
|
||||
} from "lucide-react";
|
||||
import { dashboardApi } from "@/lib/api/dashboard";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { formatCurrency } from "@/lib/utils";
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
|
||||
import Link from "next/link";
|
||||
|
||||
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
|
||||
const COLORS = ["#2563eb", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
|
||||
|
||||
function StatCard({
|
||||
icon, iconBg, label, total, loading, rows, href,
|
||||
icon,
|
||||
iconBg,
|
||||
label,
|
||||
total,
|
||||
loading,
|
||||
rows,
|
||||
href,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
iconBg: string;
|
||||
label: string;
|
||||
total: number;
|
||||
loading: boolean;
|
||||
rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
|
||||
rows: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon?: React.ReactNode;
|
||||
href: string;
|
||||
}[];
|
||||
href: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="card flex flex-col gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-3xl font-bold text-foreground tabular-nums">
|
||||
{loading ? '—' : total.toLocaleString()}
|
||||
{loading ? "—" : total.toLocaleString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.label} className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">{r.icon}{r.label}</span>
|
||||
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
|
||||
{loading ? '—' : r.value.toLocaleString()}
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{r.icon}
|
||||
{r.label}
|
||||
</span>
|
||||
<Link
|
||||
href={r.href}
|
||||
className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors"
|
||||
>
|
||||
{loading ? "—" : r.value.toLocaleString()}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<Link href={href} className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
|
||||
<Link
|
||||
href={href}
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
|
||||
>
|
||||
View all <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
@@ -50,7 +79,12 @@ function StatCard({
|
||||
}
|
||||
|
||||
function RevenueSection({
|
||||
label, bookingCount, rows, subtotal, loading, renderRow,
|
||||
label,
|
||||
bookingCount,
|
||||
rows,
|
||||
subtotal,
|
||||
loading,
|
||||
renderRow,
|
||||
}: {
|
||||
label: React.ReactNode;
|
||||
bookingCount: number;
|
||||
@@ -66,16 +100,22 @@ function RevenueSection({
|
||||
{label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{loading ? '—' : bookingCount.toLocaleString()} bookings
|
||||
{loading ? "—" : bookingCount.toLocaleString()} bookings
|
||||
</span>
|
||||
</div>
|
||||
{rows.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: rows.map(renderRow)}
|
||||
{rows.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
) : (
|
||||
rows.map(renderRow)
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
|
||||
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
|
||||
<span className="text-xs font-semibold text-muted-foreground">
|
||||
Subtotal
|
||||
</span>
|
||||
<span className="text-sm font-bold text-foreground tabular-nums">
|
||||
{formatCurrency(subtotal, "ETB")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -84,19 +124,25 @@ function RevenueSection({
|
||||
|
||||
function DashboardPageContent() {
|
||||
const { data: exchangeRates = [] } = useQuery<any[]>({
|
||||
queryKey: ['currencies'],
|
||||
queryFn: () => apiClient.get('/currencies'),
|
||||
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
|
||||
queryKey: ["currencies"],
|
||||
queryFn: () => apiClient.get("/currencies"),
|
||||
select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])),
|
||||
});
|
||||
|
||||
const toEtbRate = (currency: string): number | null => {
|
||||
if (currency === 'ETB') return 1;
|
||||
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
|
||||
if (currency === "ETB") return 1;
|
||||
const r = exchangeRates.find(
|
||||
(x: any) => x.fromCurrency === "ETB" && x.toCurrency === currency,
|
||||
);
|
||||
return r ? 1 / r.rate : null;
|
||||
};
|
||||
|
||||
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
|
||||
queryKey: ['backoffice-stats'],
|
||||
const {
|
||||
data: stats,
|
||||
isLoading: statsLoading,
|
||||
error: statsError,
|
||||
} = useQuery({
|
||||
queryKey: ["backoffice-stats"],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
retry: 1,
|
||||
staleTime: 30000,
|
||||
@@ -104,7 +150,7 @@ function DashboardPageContent() {
|
||||
});
|
||||
|
||||
const { data: paymentMethods } = useQuery({
|
||||
queryKey: ['payment-methods'],
|
||||
queryKey: ["payment-methods"],
|
||||
queryFn: dashboardApi.getPaymentMethods,
|
||||
retry: 1,
|
||||
});
|
||||
@@ -121,20 +167,31 @@ function DashboardPageContent() {
|
||||
const packageGrand = calcGrand(packageRows);
|
||||
const overallGrand = normalGrand + packageGrand;
|
||||
|
||||
const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
|
||||
const renderRevenueRow = ({
|
||||
currency,
|
||||
totalMinor,
|
||||
}: {
|
||||
currency: string;
|
||||
totalMinor: number;
|
||||
}) => {
|
||||
const rate = toEtbRate(currency);
|
||||
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
|
||||
return (
|
||||
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
|
||||
<div
|
||||
key={currency}
|
||||
className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-foreground">{currency}</span>
|
||||
<span className="text-sm font-medium text-foreground">
|
||||
{currency}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{formatCurrency(totalMinor, currency)}
|
||||
{currency !== 'ETB' && etbMinor !== null && (
|
||||
{currency !== "ETB" && etbMinor !== null && (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||
({formatCurrency(etbMinor, 'ETB')})
|
||||
({formatCurrency(etbMinor, "ETB")})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -147,15 +204,16 @@ function DashboardPageContent() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground mt-1">Welcome back! Here's your operational summary.</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Welcome back! Here's your operational summary.
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href="/boarding"
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
|
||||
title="Boarding / Gate Scan"
|
||||
className="flex items-center gap-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 text-sm font-medium transition-colors"
|
||||
>
|
||||
<ScanLine className="h-4 w-4 text-[rgb(20,113,76)]" />
|
||||
<span className="hidden sm:inline">Boarding</span>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
Boarding
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -164,39 +222,42 @@ function DashboardPageContent() {
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-5 w-5 text-orange-600 dark:text-orange-400" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-orange-800 dark:text-orange-200">Some data may be outdated</h3>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300">Unable to fetch live data. Showing cached or sample information.</p>
|
||||
<h3 className="font-semibold text-orange-800 dark:text-orange-200">
|
||||
Some data may be outdated
|
||||
</h3>
|
||||
<p className="text-sm text-orange-700 dark:text-orange-300">
|
||||
Unable to fetch live data. Showing cached or sample information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stat cards */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<StatCard
|
||||
icon={<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />}
|
||||
icon={
|
||||
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
}
|
||||
iconBg="bg-blue-100 dark:bg-blue-900/30"
|
||||
label="Bookings"
|
||||
total={stats?.totalBookings ?? 0}
|
||||
loading={statsLoading}
|
||||
href="/bookings"
|
||||
rows={[
|
||||
{ label: 'Regular', value: stats?.totalNormalBookings ?? 0, href: '/bookings' },
|
||||
{ label: 'Package', value: stats?.totalPackageBookings ?? 0, href: '/package-bookings' },
|
||||
]}
|
||||
/>
|
||||
<StatCard
|
||||
icon={<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />}
|
||||
iconBg="bg-emerald-100 dark:bg-emerald-900/30"
|
||||
label="Tickets"
|
||||
total={stats?.totalTickets ?? 0}
|
||||
loading={statsLoading}
|
||||
href="/tickets"
|
||||
rows={[
|
||||
{ label: 'Regular', value: stats?.totalNormalTickets ?? 0, href: '/tickets' },
|
||||
{ label: 'Package', value: stats?.totalPackageTickets ?? 0, href: '/tickets' },
|
||||
{
|
||||
label: "Regular",
|
||||
value: stats?.totalNormalBookings ?? 0,
|
||||
href: "/bookings",
|
||||
},
|
||||
{
|
||||
label: "Package",
|
||||
value: stats?.totalPackageBookings ?? 0,
|
||||
href: "/package-bookings",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Tickets card hidden temporarily */}
|
||||
|
||||
{/* Revenue card */}
|
||||
<div className="card flex flex-col gap-3">
|
||||
@@ -204,26 +265,35 @@ function DashboardPageContent() {
|
||||
<div className="rounded-lg bg-amber-100 dark:bg-amber-900/30 p-1.5">
|
||||
<Banknote className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Revenue</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Revenue
|
||||
</span>
|
||||
</div>
|
||||
{statsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-3xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
||||
{formatCurrency(overallGrand, 'ETB')}
|
||||
{formatCurrency(overallGrand, "ETB")}
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Regular</p>
|
||||
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(normalGrand, 'ETB')}</p>
|
||||
<p className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{formatCurrency(normalGrand, "ETB")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-muted-foreground">Package</p>
|
||||
<p className="text-sm font-semibold text-foreground tabular-nums">{formatCurrency(packageGrand, 'ETB')}</p>
|
||||
<p className="text-sm font-semibold text-foreground tabular-nums">
|
||||
{formatCurrency(packageGrand, "ETB")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/payments" className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
|
||||
<Link
|
||||
href="/payments"
|
||||
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
|
||||
>
|
||||
View payments <ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</>
|
||||
@@ -233,7 +303,9 @@ function DashboardPageContent() {
|
||||
|
||||
{/* Revenue breakdown */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">Revenue Breakdown</h2>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
Revenue Breakdown
|
||||
</h2>
|
||||
{statsLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Loading…</p>
|
||||
) : !normalRows.length && !packageRows.length ? (
|
||||
@@ -263,12 +335,25 @@ function DashboardPageContent() {
|
||||
{/* Payment Methods Distribution */}
|
||||
{paymentMethods && paymentMethods.length > 0 && (
|
||||
<div className="card">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Payment Methods Distribution</h2>
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">
|
||||
Payment Methods Distribution
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie data={paymentMethods} dataKey="count" nameKey="method" cx="50%" cy="50%" outerRadius={80} label>
|
||||
<Pie
|
||||
data={paymentMethods}
|
||||
dataKey="count"
|
||||
nameKey="method"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={80}
|
||||
label
|
||||
>
|
||||
{paymentMethods.map((_: any, index: number) => (
|
||||
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
|
||||
@@ -1,21 +1,53 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react';
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Users, Armchair, TrendingUp, Train, Download } from "lucide-react";
|
||||
import {
|
||||
BarChart,
|
||||
Bar,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
Cell,
|
||||
} from "recharts";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
|
||||
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
|
||||
const COLORS = [
|
||||
"#10b981",
|
||||
"#3b82f6",
|
||||
"#f59e0b",
|
||||
"#8b5cf6",
|
||||
"#ef4444",
|
||||
"#06b6d4",
|
||||
];
|
||||
|
||||
function StatCard({ label, value, sub, icon: Icon, color }: { label: string; value: string | number; sub?: string; icon: any; color: string }) {
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
icon: Icon,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number;
|
||||
sub?: string;
|
||||
icon: any;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</p>
|
||||
<div className={`rounded-lg p-1.5 ${color}`}><Icon className="h-4 w-4" /></div>
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{label}
|
||||
</p>
|
||||
<div className={`rounded-lg p-1.5 ${color}`}>
|
||||
<Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
|
||||
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
||||
@@ -23,47 +55,141 @@ function StatCard({ label, value, sub, icon: Icon, color }: { label: string; val
|
||||
);
|
||||
}
|
||||
|
||||
interface PassengerRow {
|
||||
bookingRef: string;
|
||||
bookingStatus: string;
|
||||
passengerName: string;
|
||||
passengerCategory: string;
|
||||
idDocumentType: string | null;
|
||||
idDocumentNumber: string | null;
|
||||
passportNumber: string | null;
|
||||
passportCountry: string | null;
|
||||
seatLabel: string | null;
|
||||
coachNumber: string | null;
|
||||
coachType: string | null;
|
||||
}
|
||||
|
||||
type Tab = "occupancy" | "list";
|
||||
|
||||
export default function PassengersReportPage() {
|
||||
const [scheduleId, setScheduleId] = useState('');
|
||||
const [scheduleId, setScheduleId] = useState("");
|
||||
const [tab, setTab] = useState<Tab>("occupancy");
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
|
||||
const { data: schedules = [] } = useQuery<any[]>({
|
||||
queryKey: ['schedules-list'],
|
||||
queryFn: () => apiClient.get('/schedules'),
|
||||
select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []),
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
||||
ScheduleOption[]
|
||||
>({
|
||||
queryKey: ["report-schedules"],
|
||||
queryFn: () => apiClient.get("/reports/schedules"),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['occupancy-report', scheduleId],
|
||||
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
||||
const { data, isLoading, isError } = useQuery<PassengersReport>({
|
||||
queryKey: ["passengers-report", scheduleId],
|
||||
queryFn: () =>
|
||||
apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const report = data as any;
|
||||
const { data: passengerList = [], isLoading: listLoading } = useQuery<
|
||||
PassengerRow[]
|
||||
>({
|
||||
queryKey: ["passengers-list", scheduleId],
|
||||
queryFn: () =>
|
||||
apiClient.get(`/reports/passengers/list?scheduleId=${scheduleId}`),
|
||||
enabled: !!scheduleId,
|
||||
});
|
||||
|
||||
const doExport = () => {
|
||||
if (!report) return;
|
||||
const rows = [
|
||||
['Coach', 'Type', 'Total Seats', 'Booked', 'Occupancy %'],
|
||||
...report.byCoach.map((c: any) => [c.coachNumber, c.coachType, c.totalSeats, c.booked, c.occupancyRate]),
|
||||
];
|
||||
const csv = rows.map(r => r.map((v: any) => `"${v}"`).join(',')).join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const filteredList = listSearch.trim()
|
||||
? passengerList.filter(
|
||||
(p) =>
|
||||
p.passengerName.toLowerCase().includes(listSearch.toLowerCase()) ||
|
||||
p.bookingRef.toLowerCase().includes(listSearch.toLowerCase()) ||
|
||||
(p.idDocumentNumber ?? "")
|
||||
.toLowerCase()
|
||||
.includes(listSearch.toLowerCase()) ||
|
||||
(p.passportNumber ?? "")
|
||||
.toLowerCase()
|
||||
.includes(listSearch.toLowerCase()),
|
||||
)
|
||||
: passengerList;
|
||||
|
||||
const downloadCsv = (csv: string, filename: string) => {
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const doExportOccupancy = () => {
|
||||
if (!data) return;
|
||||
const rows = data.byCoach.map((c) => [
|
||||
c.coachNumber,
|
||||
c.coachType,
|
||||
String(c.totalSeats),
|
||||
String(c.booked),
|
||||
`${c.occupancyRate}%`,
|
||||
]);
|
||||
downloadCsv(
|
||||
[
|
||||
["Coach", "Type", "Total Seats", "Booked", "Occupancy"].join(","),
|
||||
...rows.map((r) => r.join(",")),
|
||||
].join("\n"),
|
||||
`occupancy-${scheduleId}.csv`,
|
||||
);
|
||||
};
|
||||
|
||||
const doExportList = () => {
|
||||
if (!passengerList.length) return;
|
||||
const headers = [
|
||||
"Booking Ref",
|
||||
"Status",
|
||||
"Name",
|
||||
"Category",
|
||||
"ID Type",
|
||||
"ID Number",
|
||||
"Passport",
|
||||
"Country",
|
||||
"Seat",
|
||||
"Coach",
|
||||
"Class",
|
||||
];
|
||||
const rows = passengerList.map((p) =>
|
||||
[
|
||||
p.bookingRef,
|
||||
p.bookingStatus,
|
||||
p.passengerName,
|
||||
p.passengerCategory,
|
||||
p.idDocumentType ?? "",
|
||||
p.idDocumentNumber ?? "",
|
||||
p.passportNumber ?? "",
|
||||
p.passportCountry ?? "",
|
||||
p.seatLabel ?? "",
|
||||
p.coachNumber ?? "",
|
||||
p.coachType ?? "",
|
||||
].map((v) => `"${String(v).replace(/"/g, '""')}"`),
|
||||
);
|
||||
downloadCsv(
|
||||
[headers.join(","), ...rows.map((r) => r.join(","))].join("\n"),
|
||||
`passengers-${scheduleId}.csv`,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Passengers Report</h1>
|
||||
<p className="text-muted-foreground mt-1">Select a schedule to view passenger occupancy breakdown</p>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Passengers Report
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Select a schedule to view passenger occupancy breakdown
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Schedule Selector */}
|
||||
{/* Schedule selector */}
|
||||
<div className="card">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-64">
|
||||
@@ -71,27 +197,54 @@ export default function PassengersReportPage() {
|
||||
<select
|
||||
className="input"
|
||||
value={scheduleId}
|
||||
onChange={(e) => setScheduleId(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setScheduleId(e.target.value);
|
||||
setTab("occupancy");
|
||||
setListSearch("");
|
||||
}}
|
||||
disabled={loadingSchedules}
|
||||
>
|
||||
<option value="">— Select a schedule —</option>
|
||||
{schedules.map((s: any) => (
|
||||
<option value="">
|
||||
{loadingSchedules ? "Loading schedules…" : "Select a schedule…"}
|
||||
</option>
|
||||
{schedules.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.train?.name ?? s.train?.number ?? 'Train'} · {s.originStation?.name} → {s.destinationStation?.name} · {s.departureAt ? new Date(s.departureAt).toLocaleString() : ''}
|
||||
{s.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isFetching && <p className="text-sm text-muted-foreground self-center">Loading…</p>}
|
||||
{report && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
|
||||
{data && tab === "occupancy" && (
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
onClick={doExportOccupancy}
|
||||
>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
)}
|
||||
{passengerList.length > 0 && tab === "list" && (
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
onClick={doExportList}
|
||||
>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
{(isLoading || listLoading) && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Loading…</p>
|
||||
)}
|
||||
{isError && (
|
||||
<p className="text-xs text-red-500 mt-2">Failed to load report.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isFetching && (
|
||||
<div className="card py-12 text-center text-muted-foreground">Loading passengers data…</div>
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
Loading passengers data…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
@@ -104,204 +257,305 @@ export default function PassengersReportPage() {
|
||||
<div>
|
||||
<p className="font-semibold">{report.schedule.trainName}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{report.schedule.origin} → {report.schedule.destination} · Departure: {formatDateTime(report.schedule.departureAt)}
|
||||
{report.schedule.origin} → {report.schedule.destination} ·
|
||||
Departure: {formatDateTime(report.schedule.departureAt)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<StatCard
|
||||
label="Total Seats"
|
||||
value={report.summary.totalSeats}
|
||||
icon={Armchair}
|
||||
color="bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Passengers"
|
||||
value={report.summary.totalPassengers}
|
||||
icon={Users}
|
||||
color="bg-emerald-100 dark:bg-emerald-900/30 text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
<StatCard
|
||||
label="Occupancy Rate"
|
||||
value={`${report.summary.occupancyRate}%`}
|
||||
sub={`${report.summary.totalSeats - report.summary.totalPassengers} seats available`}
|
||||
icon={TrendingUp}
|
||||
color="bg-amber-100 dark:bg-amber-900/30 text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border flex">
|
||||
<button
|
||||
onClick={() => setTab("occupancy")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "occupancy" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Occupancy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("list")}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${tab === "list" ? "border-emerald-500 text-emerald-600 dark:text-emerald-400" : "border-transparent text-muted-foreground hover:text-foreground"}`}
|
||||
>
|
||||
Passenger List
|
||||
{passengerList.length > 0 ? ` (${passengerList.length})` : ""}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* By Coach */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Occupancy by Coach</h3>
|
||||
{report.byCoach.length > 0 ? (
|
||||
<>
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={report.byCoach} layout="vertical" margin={{ left: 8 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" horizontal={false} />
|
||||
<XAxis type="number" domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||
<YAxis type="category" dataKey="coachNumber" tick={{ fontSize: 11 }} width={56} tickFormatter={(v) => `Coach ${v}`} />
|
||||
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
|
||||
<Bar dataKey="occupancyRate" radius={[0, 3, 3, 0]}>
|
||||
{report.byCoach.map((_: any, i: number) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
<table className="w-full mt-3 text-sm">
|
||||
{/* Occupancy tab */}
|
||||
{tab === "occupancy" && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Total Seats
|
||||
</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<Armchair className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{data.summary.totalSeats}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Passengers
|
||||
</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
||||
<Users className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{data.summary.totalPassengers}
|
||||
</p>
|
||||
</div>
|
||||
<div className="card flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Occupancy Rate
|
||||
</p>
|
||||
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
|
||||
<BarChart3 className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{data.summary.occupancyRate}%
|
||||
</p>
|
||||
<div className="w-full bg-muted rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-purple-500 h-1.5 rounded-full"
|
||||
style={{ width: `${data.summary.occupancyRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">
|
||||
By Coach
|
||||
</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-xs text-muted-foreground border-b border-border">
|
||||
<th className="text-left py-1.5 font-medium">Coach</th>
|
||||
<th className="text-left py-1.5 font-medium">Type</th>
|
||||
<th className="text-right py-1.5 font-medium">Booked</th>
|
||||
<th className="text-right py-1.5 font-medium">Total</th>
|
||||
<th className="text-right py-1.5 font-medium">Rate</th>
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Coach</th>
|
||||
<th className="pb-2 pr-4">Type</th>
|
||||
<th className="pb-2 pr-4 text-right">Seats</th>
|
||||
<th className="pb-2 pr-4 text-right">Booked</th>
|
||||
<th className="pb-2">Occupancy</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.byCoach.map((c: any, i: number) => (
|
||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||
<td className="py-1.5 font-mono font-semibold">Coach {c.coachNumber}</td>
|
||||
<td className="py-1.5 text-muted-foreground">{c.coachType}</td>
|
||||
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
|
||||
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
|
||||
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
|
||||
<tbody className="divide-y divide-border">
|
||||
{data.byCoach.map((c) => (
|
||||
<tr key={c.coachNumber} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-semibold">
|
||||
{c.coachNumber}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{c.coachType}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
{c.totalSeats}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-right tabular-nums">
|
||||
{c.booked}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-emerald-500 h-1.5 rounded-full"
|
||||
style={{ width: `${c.occupancyRate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums text-xs w-10 text-right">
|
||||
{c.occupancyRate}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No coach data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Passenger List tab */}
|
||||
{tab === "list" && (
|
||||
<div className="card space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-sm"
|
||||
placeholder="Search by name, booking ref or ID…"
|
||||
value={listSearch}
|
||||
onChange={(e) => setListSearch(e.target.value)}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">#</th>
|
||||
<th className="pb-2 pr-4">Name</th>
|
||||
<th className="pb-2 pr-4">Category</th>
|
||||
<th className="pb-2 pr-4">ID / Passport</th>
|
||||
<th className="pb-2 pr-4">Seat</th>
|
||||
<th className="pb-2 pr-4">Coach</th>
|
||||
<th className="pb-2 pr-4">Booking Ref</th>
|
||||
<th className="pb-2">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredList.map((p, i) => (
|
||||
<tr
|
||||
key={`${p.bookingRef}-${i}`}
|
||||
className="hover:bg-muted/30"
|
||||
>
|
||||
<td className="py-2 pr-4 text-muted-foreground tabular-nums">
|
||||
{i + 1}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-medium">
|
||||
{p.passengerName}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<span
|
||||
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.passengerCategory === "CHILD" ? "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400" : "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400"}`}
|
||||
>
|
||||
{p.passengerCategory}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground text-xs">
|
||||
{p.idDocumentNumber ?? p.passportNumber ?? "—"}
|
||||
{p.passportCountry && (
|
||||
<span className="ml-1 text-muted-foreground/60">
|
||||
({p.passportCountry})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{p.seatLabel ?? "—"}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground">
|
||||
{p.coachNumber ?? "—"}
|
||||
{p.coachType && (
|
||||
<span className="ml-1 text-xs text-muted-foreground/60">
|
||||
({p.coachType})
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">
|
||||
{p.bookingRef}
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={`text-xs font-semibold px-1.5 py-0.5 rounded ${p.bookingStatus === "BOARDED" ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400" : "bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400"}`}
|
||||
>
|
||||
{p.bookingStatus}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</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">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
|
||||
{o.stationName}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
{filteredList.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={8}
|
||||
className="py-8 text-center text-sm text-muted-foreground"
|
||||
>
|
||||
No passengers found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No boarding station data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* By Destination */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Passengers by Alighting Station</h3>
|
||||
{report.byDestination.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.byDestination.map((d: any, i: number) => {
|
||||
const pct = report.summary.totalPassengers > 0
|
||||
? ((d.passengers / report.summary.totalPassengers) * 100).toFixed(1)
|
||||
: '0';
|
||||
return (
|
||||
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: COLORS[i % COLORS.length] }} />
|
||||
{d.stationName}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums font-semibold">{d.passengers}</td>
|
||||
<td className="py-2 text-right tabular-nums text-muted-foreground">{pct}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No alighting station data</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!report && !isFetching && scheduleId && (
|
||||
<div className="card py-12 text-center text-muted-foreground">No data found for this schedule.</div>
|
||||
<div className="card py-12 text-center text-muted-foreground">
|
||||
No data found for this schedule.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!scheduleId && (
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react';
|
||||
import { bookingsApi } from '@/lib/api';
|
||||
import { dashboardApi } from '@/lib/api/dashboard';
|
||||
import Badge from '@/components/ui/Badge';
|
||||
import ActionButton from '@/components/ui/ActionButton';
|
||||
import { formatDateTime, formatCurrency } from '@/lib/utils';
|
||||
import { useState, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Download,
|
||||
Armchair,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Ban,
|
||||
} from "lucide-react";
|
||||
import { bookingsApi, seatsApi } from "@/lib/api";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
||||
|
||||
interface SeatRow {
|
||||
bookingRef: string;
|
||||
@@ -28,12 +34,15 @@ interface SeatRow {
|
||||
const HOLD_DURATION_MS = 5 * 60 * 1000;
|
||||
|
||||
function getReleaseAt(booking: any, seat: any): string | null {
|
||||
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
|
||||
if (paymentStatus === 'SUCCEEDED' || paymentStatus === 'COMPLETED') return null;
|
||||
if (booking.status === 'CONFIRMED') return null;
|
||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED")
|
||||
return null;
|
||||
if (booking.status === "CONFIRMED") return null;
|
||||
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
|
||||
if (booking.createdAt) {
|
||||
return new Date(new Date(booking.createdAt).getTime() + HOLD_DURATION_MS).toISOString();
|
||||
return new Date(
|
||||
new Date(booking.createdAt).getTime() + HOLD_DURATION_MS,
|
||||
).toISOString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -44,17 +53,21 @@ function isExpired(releaseAt: string | null): boolean {
|
||||
}
|
||||
|
||||
export default function SeatStatusReportPage() {
|
||||
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">(
|
||||
"ALL",
|
||||
);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: stats } = useQuery({
|
||||
queryKey: ['backoffice-stats'],
|
||||
queryFn: dashboardApi.getBackofficeStats,
|
||||
staleTime: 30000,
|
||||
const { data: blockedSeats = [] } = useQuery({
|
||||
queryKey: ["blocked-seats"],
|
||||
queryFn: () =>
|
||||
seatsApi
|
||||
.getBlocked()
|
||||
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
|
||||
});
|
||||
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
queryKey: ['seat-report-bookings'],
|
||||
queryKey: ["seat-report-bookings"],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
});
|
||||
|
||||
@@ -63,25 +76,30 @@ export default function SeatStatusReportPage() {
|
||||
const result: SeatRow[] = [];
|
||||
|
||||
for (const booking of bookings) {
|
||||
if (booking.status === 'CANCELLED') continue;
|
||||
if (booking.status === "CANCELLED") continue;
|
||||
const seats: any[] = booking.seats || [];
|
||||
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
|
||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||
|
||||
for (const seat of seats) {
|
||||
result.push({
|
||||
bookingRef: booking.bookingRef || '—',
|
||||
passengerName: seat.passengerName || seat.name || booking.passengerNames?.[0] || '—',
|
||||
seatNumber: seat.seat?.seatNumber || seat.seatNumber || '—',
|
||||
coachNumber: seat.seat?.coach?.number || seat.coach || '—',
|
||||
bookingRef: booking.bookingRef || "—",
|
||||
passengerName:
|
||||
seat.passengerName ||
|
||||
seat.name ||
|
||||
booking.passengerNames?.[0] ||
|
||||
"—",
|
||||
seatNumber: seat.seat?.seatNumber || seat.seatNumber || "—",
|
||||
coachNumber: seat.seat?.coach?.number || seat.coach || "—",
|
||||
fareMinor: seat.fareMinor ?? 0,
|
||||
currency: booking.currency || 'ETB',
|
||||
currency: booking.currency || "ETB",
|
||||
paymentStatus,
|
||||
bookingStatus: booking.status,
|
||||
bookedAt: booking.createdAt,
|
||||
releaseAt: getReleaseAt(booking, seat),
|
||||
scheduleOrigin: booking.schedule?.originStation?.name || '—',
|
||||
scheduleDestination: booking.schedule?.destinationStation?.name || '—',
|
||||
scheduleDeparture: booking.schedule?.departureAt || '',
|
||||
scheduleOrigin: booking.schedule?.originStation?.name || "—",
|
||||
scheduleDestination:
|
||||
booking.schedule?.destinationStation?.name || "—",
|
||||
scheduleDeparture: booking.schedule?.departureAt || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -91,9 +109,10 @@ export default function SeatStatusReportPage() {
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED';
|
||||
if (statusFilter === 'PAID' && !isPaid) return false;
|
||||
if (statusFilter === 'UNPAID' && isPaid) return false;
|
||||
const isPaid =
|
||||
r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED";
|
||||
if (statusFilter === "PAID" && !isPaid) return false;
|
||||
if (statusFilter === "UNPAID" && isPaid) return false;
|
||||
if (search) {
|
||||
const q = search.toLowerCase();
|
||||
return (
|
||||
@@ -108,17 +127,29 @@ export default function SeatStatusReportPage() {
|
||||
}, [rows, statusFilter, search]);
|
||||
|
||||
const paidCount = rows.filter(
|
||||
(r) => r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'
|
||||
(r) => r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED",
|
||||
).length;
|
||||
const unpaidCount = rows.length - paidCount;
|
||||
const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length;
|
||||
|
||||
const doExport = () => {
|
||||
if (!filtered.length) { alert('No data to export'); return; }
|
||||
if (!filtered.length) {
|
||||
alert("No data to export");
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
'Booking Ref', 'Passenger', 'Seat', 'Coach', 'Fare',
|
||||
'Payment Status', 'Booking Status', 'Booked At', 'Release At',
|
||||
'Origin', 'Destination', 'Departure',
|
||||
"Booking Ref",
|
||||
"Passenger",
|
||||
"Seat",
|
||||
"Coach",
|
||||
"Fare",
|
||||
"Payment Status",
|
||||
"Booking Status",
|
||||
"Booked At",
|
||||
"Release At",
|
||||
"Origin",
|
||||
"Destination",
|
||||
"Departure",
|
||||
];
|
||||
const csvRows = filtered.map((r) => [
|
||||
r.bookingRef,
|
||||
@@ -128,21 +159,21 @@ export default function SeatStatusReportPage() {
|
||||
formatCurrency(r.fareMinor, r.currency),
|
||||
r.paymentStatus,
|
||||
r.bookingStatus,
|
||||
r.bookedAt ? formatDateTime(r.bookedAt) : '—',
|
||||
r.releaseAt ? formatDateTime(r.releaseAt) : '—',
|
||||
r.bookedAt ? formatDateTime(r.bookedAt) : "—",
|
||||
r.releaseAt ? formatDateTime(r.releaseAt) : "—",
|
||||
r.scheduleOrigin,
|
||||
r.scheduleDestination,
|
||||
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : '—',
|
||||
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : "—",
|
||||
]);
|
||||
const csv = [
|
||||
headers.map((h) => `"${h}"`).join(','),
|
||||
...csvRows.map((row) => row.map((v) => `"${v}"`).join(',')),
|
||||
].join('\n');
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
headers.map((h) => `"${h}"`).join(","),
|
||||
...csvRows.map((row) => row.map((v) => `"${v}"`).join(",")),
|
||||
].join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `seat-status-report-${new Date().toISOString().split('T')[0]}.csv`;
|
||||
a.download = `seat-status-report-${new Date().toISOString().split("T")[0]}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
@@ -150,9 +181,12 @@ export default function SeatStatusReportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Seat Status Report
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Track booked seats — paid vs unpaid, booking times, and hold release times
|
||||
Track booked seats — paid vs unpaid, booking times, and hold release
|
||||
times
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -161,11 +195,15 @@ export default function SeatStatusReportPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Paid Seats
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
|
||||
{paidCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Payment confirmed
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
||||
</div>
|
||||
@@ -174,11 +212,15 @@ export default function SeatStatusReportPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Unpaid Seats
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
|
||||
{unpaidCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Awaiting payment
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
||||
</div>
|
||||
@@ -187,11 +229,15 @@ export default function SeatStatusReportPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Expired Holds
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
|
||||
{expiredCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Hold time passed, not paid
|
||||
</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||
</div>
|
||||
@@ -200,14 +246,38 @@ export default function SeatStatusReportPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{stats?.blockedSeatsCount ?? '—'}
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Blocked Seats
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{blockedSeats.length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Manually blocked
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Globally blocked</p>
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||
</div>
|
||||
{blockedSeats.length > 0 && (
|
||||
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
|
||||
{blockedSeats.map((b: any) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
<span className="font-medium text-foreground">
|
||||
Seat {b.seatNumber} · Coach {b.coachNumber}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground truncate max-w-24"
|
||||
title={b.reason}
|
||||
>
|
||||
{b.reason}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -229,18 +299,27 @@ export default function SeatStatusReportPage() {
|
||||
<select
|
||||
className="input"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as 'ALL' | 'PAID' | 'UNPAID')}
|
||||
onChange={(e) =>
|
||||
setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID")
|
||||
}
|
||||
>
|
||||
<option value="ALL">All Seats</option>
|
||||
<option value="PAID">Paid Only</option>
|
||||
<option value="UNPAID">Unpaid Only</option>
|
||||
</select>
|
||||
</div>
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={isLoading}>
|
||||
<ActionButton
|
||||
icon={Download}
|
||||
variant="secondary"
|
||||
onClick={doExport}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Export CSV
|
||||
</ActionButton>
|
||||
</div>
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading...</p>}
|
||||
{isLoading && (
|
||||
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
@@ -250,14 +329,14 @@ export default function SeatStatusReportPage() {
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{[
|
||||
'Booking Ref',
|
||||
'Passenger',
|
||||
'Seat / Coach',
|
||||
'Fare',
|
||||
'Payment',
|
||||
'Booked At',
|
||||
'Release At',
|
||||
'Route',
|
||||
"Booking Ref",
|
||||
"Passenger",
|
||||
"Seat / Coach",
|
||||
"Fare",
|
||||
"Payment",
|
||||
"Booked At",
|
||||
"Release At",
|
||||
"Route",
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
@@ -271,7 +350,8 @@ export default function SeatStatusReportPage() {
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filtered.map((row, i) => {
|
||||
const isPaid =
|
||||
row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED';
|
||||
row.paymentStatus === "SUCCEEDED" ||
|
||||
row.paymentStatus === "COMPLETED";
|
||||
const expired = isExpired(row.releaseAt);
|
||||
return (
|
||||
<tr
|
||||
@@ -281,23 +361,31 @@ export default function SeatStatusReportPage() {
|
||||
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
|
||||
{row.bookingRef}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">{row.passengerName}</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
{row.passengerName}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
<span className="font-semibold">{row.seatNumber}</span>
|
||||
{row.coachNumber !== '—' && (
|
||||
<span className="text-muted-foreground"> · Coach {row.coachNumber}</span>
|
||||
{row.coachNumber !== "—" && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· Coach {row.coachNumber}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
{formatCurrency(row.fareMinor, row.currency)}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<Badge variant="status" status={isPaid ? 'PAID' : row.paymentStatus}>
|
||||
{isPaid ? 'PAID' : row.paymentStatus}
|
||||
<Badge
|
||||
variant="status"
|
||||
status={isPaid ? "PAID" : row.paymentStatus}
|
||||
>
|
||||
{isPaid ? "PAID" : row.paymentStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{row.bookedAt ? formatDateTime(row.bookedAt) : '—'}
|
||||
{row.bookedAt ? formatDateTime(row.bookedAt) : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
{isPaid ? (
|
||||
@@ -308,13 +396,13 @@ export default function SeatStatusReportPage() {
|
||||
<span
|
||||
className={
|
||||
expired
|
||||
? 'text-red-600 dark:text-red-400 text-xs font-semibold'
|
||||
: 'text-amber-600 dark:text-amber-400 text-xs font-medium'
|
||||
? "text-red-600 dark:text-red-400 text-xs font-semibold"
|
||||
: "text-amber-600 dark:text-amber-400 text-xs font-medium"
|
||||
}
|
||||
>
|
||||
{expired ? '⚠ ' : '⏱ '}
|
||||
{expired ? "⚠ " : "⏱ "}
|
||||
{formatDateTime(row.releaseAt)}
|
||||
{expired && ' (expired)'}
|
||||
{expired && " (expired)"}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
@@ -323,7 +411,9 @@ export default function SeatStatusReportPage() {
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{row.scheduleOrigin} → {row.scheduleDestination}
|
||||
{row.scheduleDeparture && (
|
||||
<div className="text-xs">{formatDateTime(row.scheduleDeparture)}</div>
|
||||
<div className="text-xs">
|
||||
{formatDateTime(row.scheduleDeparture)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -151,6 +151,7 @@ export const seatsApi = {
|
||||
return apiClient.get<any>(`/seats/seatmap/${scheduleId}${params}`);
|
||||
},
|
||||
getBySchedule: (scheduleId: string) => apiClient.get<any>(`/seats/schedule/${scheduleId}`),
|
||||
getBlocked: () => apiClient.get<any[]>('/seats/blocks'),
|
||||
hold: (data: any) => apiClient.post<any>('/seats/hold', data),
|
||||
release: (holdId: string) => apiClient.delete(`/seats/hold/${holdId}`),
|
||||
block: (seatId: string, data: any) => apiClient.post<any>(`/seats/${seatId}/block`, data),
|
||||
|
||||
Reference in New Issue
Block a user