This commit is contained in:
Roba Boru
2026-07-18 21:57:05 +03:00
9 changed files with 1130 additions and 501 deletions

View File

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

View File

@@ -1,38 +1,50 @@
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger';
import { ReportsService } from './reports.service';
import { GenerateReportDto } from './reports.dto';
import { PassengerStaff } from '../../common/passenger-guards';
import { PASSENGER_PERMS } from '../../seed/passenger-permissions.registry';
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { ApiTags, ApiOperation, ApiBearerAuth } from "@nestjs/swagger";
import { ReportsService } from "./reports.service";
import { GenerateReportDto } from "./reports.dto";
import { PassengerStaff } from "../../common/passenger-guards";
import { PASSENGER_PERMS } from "../../seed/passenger-permissions.registry";
@ApiTags('Reports')
@Controller('reports')
@ApiTags("Reports")
@Controller("reports")
@PassengerStaff([PASSENGER_PERMS.reports.view, PASSENGER_PERMS.admin])
@ApiBearerAuth('IAM-auth')
@ApiBearerAuth("IAM-auth")
export class ReportsController {
constructor(private service: ReportsService) {}
@Post('generate')
@ApiOperation({ summary: 'Generate operational report' })
@Post("generate")
@ApiOperation({ summary: "Generate operational report" })
generateReport(@Body() dto: GenerateReportDto) {
return this.service.generateReport(dto);
}
@Get('passengers')
@ApiOperation({ summary: 'Passengers report for a specific schedule' })
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
@Get("schedules")
@ApiOperation({ summary: "List schedules for the passengers report picker" })
listSchedulesForPicker() {
return this.service.listSchedulesForPicker();
}
@Get("passengers/list")
@ApiOperation({ summary: "Flat passenger list for a specific schedule" })
getPassengerList(@Query("scheduleId") scheduleId: string) {
return this.service.getPassengerList(scheduleId);
}
@Get("passengers")
@ApiOperation({ summary: "Passengers report for a specific schedule" })
getOccupancyReport(@Query("scheduleId") scheduleId: string) {
return this.service.getOccupancyBySchedule(scheduleId);
}
@Get(':reportId')
@ApiOperation({ summary: 'Get report by ID' })
getReport(@Param('reportId') reportId: string) {
@Get(":reportId")
@ApiOperation({ summary: "Get report by ID" })
getReport(@Param("reportId") reportId: string) {
return this.service.getReport(reportId);
}
@Get()
@ApiOperation({ summary: 'List reports' })
listReports(@Query('type') type?: string) {
@ApiOperation({ summary: "List reports" })
listReports(@Query("type") type?: string) {
return this.service.listReports(type);
}
}

View File

@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource } from 'typeorm';
import { PrismaService } from '../../common/prisma.service';
import { GenerateReportDto, ReportType } from './reports.dto';
import { Injectable, Logger } from "@nestjs/common";
import { InjectDataSource } from "@nestjs/typeorm";
import { DataSource } from "typeorm";
import { PrismaService } from "../../common/prisma.service";
import { GenerateReportDto, ReportType } from "./reports.dto";
@Injectable()
export class ReportsService {
@@ -15,7 +15,7 @@ export class ReportsService {
async generateReport(dto: GenerateReportDto) {
const dateFrom = new Date(dto.dateFrom);
dateFrom.setHours(0, 0, 0, 0);
const dateTo = new Date(dto.dateTo);
dateTo.setHours(23, 59, 59, 999);
@@ -28,7 +28,11 @@ export class ReportsService {
data = await this.generateOccupancyReport(dateFrom, dateTo);
break;
case ReportType.AGENT_SALES:
data = await this.generateAgentSalesReport(dateFrom, dateTo, dto.agentId);
data = await this.generateAgentSalesReport(
dateFrom,
dateTo,
dto.agentId,
);
break;
case ReportType.CANCELLATIONS:
data = await this.generateCancellationsReport(dateFrom, dateTo);
@@ -45,8 +49,8 @@ export class ReportsService {
reportType: dto.reportType,
dateFrom,
dateTo,
data
}
data,
},
});
return { reportId: report.id, reportType: dto.reportType, data };
@@ -56,37 +60,43 @@ export class ReportsService {
// Fetch all bookings in date range, regardless of status
const bookings = await this.prisma.booking.findMany({
where: {
createdAt: { gte: dateFrom, lte: dateTo }
createdAt: { gte: dateFrom, lte: dateTo },
},
include: { paymentIntent: true }
include: { paymentIntent: true },
});
const totalRevenue = bookings.reduce((sum, b) => sum + b.totalMinor, 0);
const byPaymentMethod = bookings.reduce((acc, b) => {
const method = b.paymentIntent?.method ?? 'UNKNOWN';
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
}, {} as Record<string, number>);
const byPaymentMethod = bookings.reduce(
(acc, b) => {
const method = b.paymentIntent?.method ?? "UNKNOWN";
acc[method] = (acc[method] || 0) + b.totalMinor;
return acc;
},
{} as Record<string, number>,
);
// Group by date for charts
const byDate = bookings.reduce((acc, b) => {
const date = b.createdAt.toISOString().split('T')[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
const byDate = bookings.reduce(
(acc, b) => {
const date = b.createdAt.toISOString().split("T")[0];
if (!acc[date]) {
acc[date] = { totalMinor: 0, count: 0 };
}
acc[date].totalMinor += b.totalMinor;
acc[date].count += 1;
return acc;
},
{} as Record<string, any>,
);
return {
totalBookings: bookings.length,
totalRevenueMinor: totalRevenue,
totalRevenue: totalRevenue / 100,
currency: 'ETB',
currency: "ETB",
byPaymentMethod,
byDate,
cancellationRate: 0
cancellationRate: 0,
};
}
@@ -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,
});
}
}

View File

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

View File

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

View File

@@ -1,48 +1,77 @@
'use client';
"use client";
import { useQuery } from '@tanstack/react-query';
import { PermissionGuard } from '@/components/layout/PermissionGuard';
import { PERMS } from '@/lib/permissions';
import { Ticket, AlertCircle, BookOpen, Banknote, ArrowRight, ScanLine } from 'lucide-react';
import { dashboardApi } from '@/lib/api/dashboard';
import { apiClient } from '@/lib/api-client';
import { formatCurrency } from '@/lib/utils';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from 'recharts';
import Link from 'next/link';
import { useQuery } from "@tanstack/react-query";
import { PermissionGuard } from "@/components/layout/PermissionGuard";
import { PERMS } from "@/lib/permissions";
import {
Ticket,
AlertCircle,
BookOpen,
Banknote,
ArrowRight,
ScanLine,
} from "lucide-react";
import { dashboardApi } from "@/lib/api/dashboard";
import { apiClient } from "@/lib/api-client";
import { formatCurrency } from "@/lib/utils";
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
import Link from "next/link";
const COLORS = ['#2563eb', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6'];
const COLORS = ["#2563eb", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6"];
function StatCard({
icon, iconBg, label, total, loading, rows, href,
icon,
iconBg,
label,
total,
loading,
rows,
href,
}: {
icon: React.ReactNode;
iconBg: string;
label: string;
total: number;
loading: boolean;
rows: { label: string; value: number; icon?: React.ReactNode; href: string }[];
rows: {
label: string;
value: number;
icon?: React.ReactNode;
href: string;
}[];
href: string;
}) {
return (
<div className="card flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className={`rounded-lg ${iconBg} p-1.5`}>{icon}</div>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</span>
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</span>
</div>
<p className="text-3xl font-bold text-foreground tabular-nums">
{loading ? '—' : total.toLocaleString()}
{loading ? "—" : total.toLocaleString()}
</p>
<div className="flex flex-col gap-2 border-t border-border pt-3">
{rows.map((r) => (
<div key={r.label} className="flex items-center justify-between">
<span className="flex items-center gap-1 text-xs text-muted-foreground">{r.icon}{r.label}</span>
<Link href={r.href} className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors">
{loading ? '—' : r.value.toLocaleString()}
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{r.icon}
{r.label}
</span>
<Link
href={r.href}
className="text-sm font-semibold text-foreground tabular-nums hover:text-primary transition-colors"
>
{loading ? "—" : r.value.toLocaleString()}
</Link>
</div>
))}
</div>
<Link href={href} className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1">
<Link
href={href}
className="flex items-center gap-1 text-xs text-primary hover:underline mt-auto pt-1"
>
View all <ArrowRight className="h-3 w-3" />
</Link>
</div>
@@ -50,7 +79,12 @@ function StatCard({
}
function RevenueSection({
label, bookingCount, rows, subtotal, loading, renderRow,
label,
bookingCount,
rows,
subtotal,
loading,
renderRow,
}: {
label: React.ReactNode;
bookingCount: number;
@@ -66,16 +100,22 @@ function RevenueSection({
{label}
</span>
<span className="text-xs text-muted-foreground tabular-nums">
{loading ? '—' : bookingCount.toLocaleString()} bookings
{loading ? "—" : bookingCount.toLocaleString()} bookings
</span>
</div>
{rows.length === 0
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
: rows.map(renderRow)}
{rows.length === 0 ? (
<p className="text-xs text-muted-foreground py-1">No revenue yet</p>
) : (
rows.map(renderRow)
)}
{rows.length > 0 && (
<div className="flex items-center justify-between rounded-md bg-muted/40 px-3 py-2 mt-1">
<span className="text-xs font-semibold text-muted-foreground">Subtotal</span>
<span className="text-sm font-bold text-foreground tabular-nums">{formatCurrency(subtotal, 'ETB')}</span>
<span className="text-xs font-semibold text-muted-foreground">
Subtotal
</span>
<span className="text-sm font-bold text-foreground tabular-nums">
{formatCurrency(subtotal, "ETB")}
</span>
</div>
)}
</div>
@@ -84,19 +124,25 @@ function RevenueSection({
function DashboardPageContent() {
const { data: exchangeRates = [] } = useQuery<any[]>({
queryKey: ['currencies'],
queryFn: () => apiClient.get('/currencies'),
select: (d: any) => (Array.isArray(d) ? d : d?.data ?? d?.items ?? []),
queryKey: ["currencies"],
queryFn: () => apiClient.get("/currencies"),
select: (d: any) => (Array.isArray(d) ? d : (d?.data ?? d?.items ?? [])),
});
const toEtbRate = (currency: string): number | null => {
if (currency === 'ETB') return 1;
const r = exchangeRates.find((x: any) => x.fromCurrency === 'ETB' && x.toCurrency === currency);
if (currency === "ETB") return 1;
const r = exchangeRates.find(
(x: any) => x.fromCurrency === "ETB" && x.toCurrency === currency,
);
return r ? 1 / r.rate : null;
};
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['backoffice-stats'],
const {
data: stats,
isLoading: statsLoading,
error: statsError,
} = useQuery({
queryKey: ["backoffice-stats"],
queryFn: dashboardApi.getBackofficeStats,
retry: 1,
staleTime: 30000,
@@ -104,7 +150,7 @@ function DashboardPageContent() {
});
const { data: paymentMethods } = useQuery({
queryKey: ['payment-methods'],
queryKey: ["payment-methods"],
queryFn: dashboardApi.getPaymentMethods,
retry: 1,
});
@@ -121,20 +167,31 @@ function DashboardPageContent() {
const packageGrand = calcGrand(packageRows);
const overallGrand = normalGrand + packageGrand;
const renderRevenueRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => {
const renderRevenueRow = ({
currency,
totalMinor,
}: {
currency: string;
totalMinor: number;
}) => {
const rate = toEtbRate(currency);
const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null;
return (
<div key={currency} className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2">
<div
key={currency}
className="flex items-center justify-between rounded-md bg-muted/20 px-3 py-2"
>
<div className="flex items-center gap-1.5">
<Banknote className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{currency}</span>
<span className="text-sm font-medium text-foreground">
{currency}
</span>
</div>
<span className="text-sm font-semibold text-foreground tabular-nums">
{formatCurrency(totalMinor, currency)}
{currency !== 'ETB' && etbMinor !== null && (
{currency !== "ETB" && etbMinor !== null && (
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
({formatCurrency(etbMinor, 'ETB')})
({formatCurrency(etbMinor, "ETB")})
</span>
)}
</span>
@@ -147,15 +204,16 @@ function DashboardPageContent() {
<div className="flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Dashboard</h1>
<p className="text-muted-foreground mt-1">Welcome back! Here&apos;s your operational summary.</p>
<p className="text-muted-foreground mt-1">
Welcome back! Here&apos;s your operational summary.
</p>
</div>
<Link
href="/boarding"
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2 text-sm font-medium text-foreground hover:bg-muted transition-colors"
title="Boarding / Gate Scan"
className="flex items-center gap-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 text-sm font-medium transition-colors"
>
<ScanLine className="h-4 w-4 text-[rgb(20,113,76)]" />
<span className="hidden sm:inline">Boarding</span>
<ScanLine className="h-4 w-4" />
Boarding
</Link>
</div>
@@ -164,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 />

View File

@@ -1,21 +1,53 @@
'use client';
"use client";
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Users, Armchair, TrendingUp, Train, Download } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell } from 'recharts';
import { apiClient } from '@/lib/api-client';
import { formatDateTime } from '@/lib/utils';
import ActionButton from '@/components/ui/ActionButton';
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Users, Armchair, TrendingUp, Train, Download } from "lucide-react";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { apiClient } from "@/lib/api-client";
import { formatDateTime } from "@/lib/utils";
import ActionButton from "@/components/ui/ActionButton";
const COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#8b5cf6', '#ef4444', '#06b6d4'];
const COLORS = [
"#10b981",
"#3b82f6",
"#f59e0b",
"#8b5cf6",
"#ef4444",
"#06b6d4",
];
function StatCard({ label, value, sub, icon: Icon, color }: { label: string; value: string | number; sub?: string; icon: any; color: string }) {
function StatCard({
label,
value,
sub,
icon: Icon,
color,
}: {
label: string;
value: string | number;
sub?: string;
icon: any;
color: string;
}) {
return (
<div className="card flex flex-col gap-1">
<div className="flex items-center justify-between">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</p>
<div className={`rounded-lg p-1.5 ${color}`}><Icon className="h-4 w-4" /></div>
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{label}
</p>
<div className={`rounded-lg p-1.5 ${color}`}>
<Icon className="h-4 w-4" />
</div>
</div>
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
@@ -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 && (

View File

@@ -1,13 +1,19 @@
'use client';
"use client";
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Armchair, CheckCircle, Clock, AlertCircle, Ban } from 'lucide-react';
import { bookingsApi } from '@/lib/api';
import { dashboardApi } from '@/lib/api/dashboard';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import { useState, useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import {
Download,
Armchair,
CheckCircle,
Clock,
AlertCircle,
Ban,
} from "lucide-react";
import { bookingsApi, seatsApi } from "@/lib/api";
import Badge from "@/components/ui/Badge";
import ActionButton from "@/components/ui/ActionButton";
import { formatDateTime, formatCurrency } from "@/lib/utils";
interface SeatRow {
bookingRef: string;
@@ -28,12 +34,15 @@ interface SeatRow {
const HOLD_DURATION_MS = 5 * 60 * 1000;
function getReleaseAt(booking: any, seat: any): string | null {
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
if (paymentStatus === 'SUCCEEDED' || paymentStatus === 'COMPLETED') return null;
if (booking.status === 'CONFIRMED') return null;
const paymentStatus = booking.paymentIntent?.status || "PENDING";
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED")
return null;
if (booking.status === "CONFIRMED") return null;
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
if (booking.createdAt) {
return new Date(new Date(booking.createdAt).getTime() + HOLD_DURATION_MS).toISOString();
return new Date(
new Date(booking.createdAt).getTime() + HOLD_DURATION_MS,
).toISOString();
}
return null;
}
@@ -44,17 +53,21 @@ function isExpired(releaseAt: string | null): boolean {
}
export default function SeatStatusReportPage() {
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">(
"ALL",
);
const [search, setSearch] = useState("");
const { data: stats } = useQuery({
queryKey: ['backoffice-stats'],
queryFn: dashboardApi.getBackofficeStats,
staleTime: 30000,
const { data: blockedSeats = [] } = useQuery({
queryKey: ["blocked-seats"],
queryFn: () =>
seatsApi
.getBlocked()
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
});
const { data: bookingsData, isLoading } = useQuery({
queryKey: ['seat-report-bookings'],
queryKey: ["seat-report-bookings"],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
@@ -63,25 +76,30 @@ export default function SeatStatusReportPage() {
const result: SeatRow[] = [];
for (const booking of bookings) {
if (booking.status === 'CANCELLED') continue;
if (booking.status === "CANCELLED") continue;
const seats: any[] = booking.seats || [];
const paymentStatus = booking.paymentIntent?.status || 'PENDING';
const paymentStatus = booking.paymentIntent?.status || "PENDING";
for (const seat of seats) {
result.push({
bookingRef: booking.bookingRef || '—',
passengerName: seat.passengerName || seat.name || booking.passengerNames?.[0] || '—',
seatNumber: seat.seat?.seatNumber || seat.seatNumber || '—',
coachNumber: seat.seat?.coach?.number || seat.coach || '—',
bookingRef: booking.bookingRef || "—",
passengerName:
seat.passengerName ||
seat.name ||
booking.passengerNames?.[0] ||
"—",
seatNumber: seat.seat?.seatNumber || seat.seatNumber || "—",
coachNumber: seat.seat?.coach?.number || seat.coach || "—",
fareMinor: seat.fareMinor ?? 0,
currency: booking.currency || 'ETB',
currency: booking.currency || "ETB",
paymentStatus,
bookingStatus: booking.status,
bookedAt: booking.createdAt,
releaseAt: getReleaseAt(booking, seat),
scheduleOrigin: booking.schedule?.originStation?.name || '—',
scheduleDestination: booking.schedule?.destinationStation?.name || '—',
scheduleDeparture: booking.schedule?.departureAt || '',
scheduleOrigin: booking.schedule?.originStation?.name || "—",
scheduleDestination:
booking.schedule?.destinationStation?.name || "—",
scheduleDeparture: booking.schedule?.departureAt || "",
});
}
}
@@ -91,9 +109,10 @@ export default function SeatStatusReportPage() {
const filtered = useMemo(() => {
return rows.filter((r) => {
const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED';
if (statusFilter === 'PAID' && !isPaid) return false;
if (statusFilter === 'UNPAID' && isPaid) return false;
const isPaid =
r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED";
if (statusFilter === "PAID" && !isPaid) return false;
if (statusFilter === "UNPAID" && isPaid) return false;
if (search) {
const q = search.toLowerCase();
return (
@@ -108,17 +127,29 @@ export default function SeatStatusReportPage() {
}, [rows, statusFilter, search]);
const paidCount = rows.filter(
(r) => r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'
(r) => r.paymentStatus === "SUCCEEDED" || r.paymentStatus === "COMPLETED",
).length;
const unpaidCount = rows.length - paidCount;
const expiredCount = rows.filter((r) => isExpired(r.releaseAt)).length;
const doExport = () => {
if (!filtered.length) { alert('No data to export'); return; }
if (!filtered.length) {
alert("No data to export");
return;
}
const headers = [
'Booking Ref', 'Passenger', 'Seat', 'Coach', 'Fare',
'Payment Status', 'Booking Status', 'Booked At', 'Release At',
'Origin', 'Destination', 'Departure',
"Booking Ref",
"Passenger",
"Seat",
"Coach",
"Fare",
"Payment Status",
"Booking Status",
"Booked At",
"Release At",
"Origin",
"Destination",
"Departure",
];
const csvRows = filtered.map((r) => [
r.bookingRef,
@@ -128,21 +159,21 @@ export default function SeatStatusReportPage() {
formatCurrency(r.fareMinor, r.currency),
r.paymentStatus,
r.bookingStatus,
r.bookedAt ? formatDateTime(r.bookedAt) : '—',
r.releaseAt ? formatDateTime(r.releaseAt) : '—',
r.bookedAt ? formatDateTime(r.bookedAt) : "—",
r.releaseAt ? formatDateTime(r.releaseAt) : "—",
r.scheduleOrigin,
r.scheduleDestination,
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : '—',
r.scheduleDeparture ? formatDateTime(r.scheduleDeparture) : "—",
]);
const csv = [
headers.map((h) => `"${h}"`).join(','),
...csvRows.map((row) => row.map((v) => `"${v}"`).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv' });
headers.map((h) => `"${h}"`).join(","),
...csvRows.map((row) => row.map((v) => `"${v}"`).join(",")),
].join("\n");
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = `seat-status-report-${new Date().toISOString().split('T')[0]}.csv`;
a.download = `seat-status-report-${new Date().toISOString().split("T")[0]}.csv`;
a.click();
URL.revokeObjectURL(url);
};
@@ -150,9 +181,12 @@ export default function SeatStatusReportPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
<h1 className="text-3xl font-bold text-foreground">
Seat Status Report
</h1>
<p className="text-muted-foreground mt-1">
Track booked seats paid vs unpaid, booking times, and hold release times
Track booked seats paid vs unpaid, booking times, and hold release
times
</p>
</div>
@@ -161,11 +195,15 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
<p className="text-muted-foreground text-sm font-medium">
Paid Seats
</p>
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
{paidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
<p className="text-xs text-muted-foreground mt-1">
Payment confirmed
</p>
</div>
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
</div>
@@ -174,11 +212,15 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
<p className="text-muted-foreground text-sm font-medium">
Unpaid Seats
</p>
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
{unpaidCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
<p className="text-xs text-muted-foreground mt-1">
Awaiting payment
</p>
</div>
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
</div>
@@ -187,11 +229,15 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
<p className="text-muted-foreground text-sm font-medium">
Expired Holds
</p>
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
{expiredCount}
</p>
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</p>
<p className="text-xs text-muted-foreground mt-1">
Hold time passed, not paid
</p>
</div>
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
</div>
@@ -200,14 +246,38 @@ export default function SeatStatusReportPage() {
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
{stats?.blockedSeatsCount ?? '—'}
<p className="text-muted-foreground text-sm font-medium">
Blocked Seats
</p>
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
{blockedSeats.length}
</p>
<p className="text-xs text-muted-foreground mt-1">
Manually blocked
</p>
<p className="text-xs text-muted-foreground mt-1">Globally blocked</p>
</div>
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
</div>
{blockedSeats.length > 0 && (
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
{blockedSeats.map((b: any) => (
<div
key={b.id}
className="flex items-center justify-between text-xs"
>
<span className="font-medium text-foreground">
Seat {b.seatNumber} · Coach {b.coachNumber}
</span>
<span
className="text-muted-foreground truncate max-w-24"
title={b.reason}
>
{b.reason}
</span>
</div>
))}
</div>
)}
</div>
</div>
@@ -229,18 +299,27 @@ export default function SeatStatusReportPage() {
<select
className="input"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'ALL' | 'PAID' | 'UNPAID')}
onChange={(e) =>
setStatusFilter(e.target.value as "ALL" | "PAID" | "UNPAID")
}
>
<option value="ALL">All Seats</option>
<option value="PAID">Paid Only</option>
<option value="UNPAID">Unpaid Only</option>
</select>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={isLoading}>
<ActionButton
icon={Download}
variant="secondary"
onClick={doExport}
disabled={isLoading}
>
Export CSV
</ActionButton>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading...</p>}
{isLoading && (
<p className="text-xs text-muted-foreground mt-2">Loading...</p>
)}
</div>
{/* Table */}
@@ -250,14 +329,14 @@ export default function SeatStatusReportPage() {
<thead className="bg-gray-50 dark:bg-gray-800">
<tr>
{[
'Booking Ref',
'Passenger',
'Seat / Coach',
'Fare',
'Payment',
'Booked At',
'Release At',
'Route',
"Booking Ref",
"Passenger",
"Seat / Coach",
"Fare",
"Payment",
"Booked At",
"Release At",
"Route",
].map((h) => (
<th
key={h}
@@ -271,7 +350,8 @@ export default function SeatStatusReportPage() {
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{filtered.map((row, i) => {
const isPaid =
row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED';
row.paymentStatus === "SUCCEEDED" ||
row.paymentStatus === "COMPLETED";
const expired = isExpired(row.releaseAt);
return (
<tr
@@ -281,23 +361,31 @@ export default function SeatStatusReportPage() {
<td className="px-4 py-3 text-sm font-mono font-semibold whitespace-nowrap">
{row.bookingRef}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">{row.passengerName}</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{row.passengerName}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
<span className="font-semibold">{row.seatNumber}</span>
{row.coachNumber !== '—' && (
<span className="text-muted-foreground"> · Coach {row.coachNumber}</span>
{row.coachNumber !== "—" && (
<span className="text-muted-foreground">
{" "}
· Coach {row.coachNumber}
</span>
)}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{formatCurrency(row.fareMinor, row.currency)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<Badge variant="status" status={isPaid ? 'PAID' : row.paymentStatus}>
{isPaid ? 'PAID' : row.paymentStatus}
<Badge
variant="status"
status={isPaid ? "PAID" : row.paymentStatus}
>
{isPaid ? "PAID" : row.paymentStatus}
</Badge>
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.bookedAt ? formatDateTime(row.bookedAt) : '—'}
{row.bookedAt ? formatDateTime(row.bookedAt) : "—"}
</td>
<td className="px-4 py-3 text-sm whitespace-nowrap">
{isPaid ? (
@@ -308,13 +396,13 @@ export default function SeatStatusReportPage() {
<span
className={
expired
? 'text-red-600 dark:text-red-400 text-xs font-semibold'
: 'text-amber-600 dark:text-amber-400 text-xs font-medium'
? "text-red-600 dark:text-red-400 text-xs font-semibold"
: "text-amber-600 dark:text-amber-400 text-xs font-medium"
}
>
{expired ? '' : ''}
{expired ? "" : ""}
{formatDateTime(row.releaseAt)}
{expired && ' (expired)'}
{expired && " (expired)"}
</span>
) : (
<span className="text-muted-foreground text-xs"></span>
@@ -323,7 +411,9 @@ export default function SeatStatusReportPage() {
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
{row.scheduleOrigin} {row.scheduleDestination}
{row.scheduleDeparture && (
<div className="text-xs">{formatDateTime(row.scheduleDeparture)}</div>
<div className="text-xs">
{formatDateTime(row.scheduleDeparture)}
</div>
)}
</td>
</tr>

View File

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