mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 15:25:45 +00:00
Package pricing issue resolution, passenger report, tickets filter updates
This commit is contained in:
@@ -320,8 +320,8 @@ export class BookingsService {
|
|||||||
id: b.id,
|
id: b.id,
|
||||||
bookingRef: b.bookingRef,
|
bookingRef: b.bookingRef,
|
||||||
status: b.status,
|
status: b.status,
|
||||||
totalMinor: b.totalMinor,
|
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||||
currency: b.currency || null,
|
currency: b.displayCurrency ?? b.currency ?? null,
|
||||||
displayCurrency: b.displayCurrency ?? null,
|
displayCurrency: b.displayCurrency ?? null,
|
||||||
displayTotalMinor: b.displayTotalMinor ?? null,
|
displayTotalMinor: b.displayTotalMinor ?? null,
|
||||||
adultCount: b.adultCount,
|
adultCount: b.adultCount,
|
||||||
@@ -578,7 +578,7 @@ export class BookingsService {
|
|||||||
|
|
||||||
const mappedPkg = pkgItems.map((b: any) => ({
|
const mappedPkg = pkgItems.map((b: any) => ({
|
||||||
id: b.id, bookingRef: b.bookingRef, status: b.status,
|
id: b.id, bookingRef: b.bookingRef, status: b.status,
|
||||||
totalMinor: b.totalMinor, currency: b.currency || b.displayCurrency,
|
totalMinor: b.displayTotalMinor ?? b.totalMinor, currency: b.displayCurrency || b.currency,
|
||||||
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
displayCurrency: b.displayCurrency, displayTotalMinor: b.displayTotalMinor,
|
||||||
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
contactEmail: b.contactEmail, contactPhone: b.contactPhone,
|
||||||
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
bookingType: 'PACKAGE', packageId: b.packageId, priceTierId: b.priceTierId,
|
||||||
@@ -732,8 +732,8 @@ export class BookingsService {
|
|||||||
id: b.id,
|
id: b.id,
|
||||||
bookingRef: b.bookingRef,
|
bookingRef: b.bookingRef,
|
||||||
status: b.status,
|
status: b.status,
|
||||||
totalMinor: b.totalMinor,
|
totalMinor: b.displayTotalMinor ?? b.totalMinor,
|
||||||
currency: b.currency || b.displayCurrency,
|
currency: b.displayCurrency || b.currency,
|
||||||
displayCurrency: b.displayCurrency,
|
displayCurrency: b.displayCurrency,
|
||||||
displayTotalMinor: b.displayTotalMinor,
|
displayTotalMinor: b.displayTotalMinor,
|
||||||
contactEmail: b.contactEmail,
|
contactEmail: b.contactEmail,
|
||||||
@@ -1822,8 +1822,8 @@ export class BookingsService {
|
|||||||
id: pkgBooking.id,
|
id: pkgBooking.id,
|
||||||
bookingRef: pkgBooking.bookingRef,
|
bookingRef: pkgBooking.bookingRef,
|
||||||
status: pkgBooking.status,
|
status: pkgBooking.status,
|
||||||
totalMinor: pkgBooking.totalMinor,
|
totalMinor: pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor,
|
||||||
currency: pkgBooking.currency || pkgBooking.displayCurrency,
|
currency: pkgBooking.displayCurrency || pkgBooking.currency,
|
||||||
adultCount: pkgBooking.passengerCount,
|
adultCount: pkgBooking.passengerCount,
|
||||||
childCount: 0,
|
childCount: 0,
|
||||||
displayCurrency: pkgBooking.displayCurrency,
|
displayCurrency: pkgBooking.displayCurrency,
|
||||||
@@ -1852,7 +1852,7 @@ export class BookingsService {
|
|||||||
fullName: p.passengerName,
|
fullName: p.passengerName,
|
||||||
category: 'ADULT',
|
category: 'ADULT',
|
||||||
leg: 1,
|
leg: 1,
|
||||||
fareMinor: Math.round(pkgBooking.totalMinor / pkgBooking.passengerCount),
|
fareMinor: Math.round((pkgBooking.displayTotalMinor ?? pkgBooking.totalMinor) / pkgBooking.passengerCount),
|
||||||
verifaydaVerified: false,
|
verifaydaVerified: false,
|
||||||
seat: null,
|
seat: null,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ export class ReportsController {
|
|||||||
return this.service.generateReport(dto);
|
return this.service.generateReport(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('occupancy')
|
||||||
|
@ApiOperation({ summary: 'Occupancy report for a specific schedule' })
|
||||||
|
getOccupancyReport(@Query('scheduleId') scheduleId: string) {
|
||||||
|
return this.service.getOccupancyBySchedule(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':reportId')
|
@Get(':reportId')
|
||||||
@ApiOperation({ summary: 'Get report by ID' })
|
@ApiOperation({ summary: 'Get report by ID' })
|
||||||
getReport(@Param('reportId') reportId: string) {
|
getReport(@Param('reportId') reportId: string) {
|
||||||
|
|||||||
@@ -191,6 +191,122 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getOccupancyBySchedule(scheduleId: string) {
|
||||||
|
const schedule = await this.prisma.trainSchedule.findUnique({
|
||||||
|
where: { id: scheduleId },
|
||||||
|
include: {
|
||||||
|
originStation: true,
|
||||||
|
destinationStation: true,
|
||||||
|
train: true,
|
||||||
|
coachAssignments: {
|
||||||
|
include: {
|
||||||
|
coach: {
|
||||||
|
include: {
|
||||||
|
coachType: true,
|
||||||
|
seats: { select: { id: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bookings: {
|
||||||
|
where: { status: { in: ['CONFIRMED', 'BOARDED'] } },
|
||||||
|
include: {
|
||||||
|
seats: {
|
||||||
|
include: {
|
||||||
|
seat: { include: { coach: { include: { coachType: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 totalPassengers = allBookingSeats.length;
|
||||||
|
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 }>();
|
||||||
|
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',
|
||||||
|
totalSeats: c.seats.length,
|
||||||
|
booked: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const bs of allBookingSeats) {
|
||||||
|
const coachId = bs.seat?.coachId;
|
||||||
|
if (coachId && coachMap.has(coachId)) coachMap.get(coachId)!.booked++;
|
||||||
|
}
|
||||||
|
const byCoach = [...coachMap.values()].map(c => ({
|
||||||
|
...c,
|
||||||
|
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 }>();
|
||||||
|
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 });
|
||||||
|
originMap.get(stationId)!.passengers += booking.seats.length;
|
||||||
|
}
|
||||||
|
const byOrigin = [...originMap.values()].sort((a, b) => b.passengers - a.passengers);
|
||||||
|
|
||||||
|
// Per-destination station breakdown
|
||||||
|
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 });
|
||||||
|
destMap.get(stationId)!.passengers += booking.seats.length;
|
||||||
|
}
|
||||||
|
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 }>();
|
||||||
|
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 });
|
||||||
|
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 });
|
||||||
|
classMap.get(typeName)!.booked++;
|
||||||
|
}
|
||||||
|
const byClass = [...classMap.values()].map(c => ({
|
||||||
|
...c,
|
||||||
|
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,
|
||||||
|
origin: (schedule as any).originStation?.name,
|
||||||
|
destination: (schedule as any).destinationStation?.name,
|
||||||
|
departureAt: schedule.departureAt,
|
||||||
|
arrivalAt: schedule.arrivalAt,
|
||||||
|
},
|
||||||
|
summary: { totalSeats, totalPassengers, occupancyRate },
|
||||||
|
byCoach,
|
||||||
|
byClass,
|
||||||
|
byOrigin,
|
||||||
|
byDestination,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getReport(reportId: string) {
|
async getReport(reportId: string) {
|
||||||
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
return this.prisma.operationalReport.findUnique({ where: { id: reportId } });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,11 +54,16 @@ export class CreateScheduleDto {
|
|||||||
plannedTimes?: PlannedStopTimeDto[];
|
plannedTimes?: PlannedStopTimeDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CoachAssignmentDto {
|
||||||
|
@ApiProperty({ example: 'coach-uuid' }) @IsString() coachId: string;
|
||||||
|
@ApiProperty({ example: 1 }) @IsInt() @Min(1) positionNumber: number;
|
||||||
|
}
|
||||||
|
|
||||||
export class UpdateScheduleDto {
|
export class UpdateScheduleDto {
|
||||||
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
@ApiPropertyOptional({ example: '2026-06-15T08:00:00Z', description: 'Scheduled departure from the first stop (origin)' }) @IsOptional() @IsDateString() departureAt?: string;
|
||||||
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
@ApiPropertyOptional({ example: '2026-06-15T20:00:00Z', description: 'Scheduled arrival at the last stop (destination)' }) @IsOptional() @IsDateString() arrivalAt?: string;
|
||||||
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
@ApiPropertyOptional({ enum: TripStatus, example: TripStatus.SCHEDULED }) @IsOptional() @IsEnum(TripStatus) status?: TripStatus;
|
||||||
@ApiPropertyOptional({ type: Array, description: 'List of coaches to assign' }) @IsOptional() @IsArray() coaches?: Array<{ coachId: string; positionNumber: number }>;
|
@ApiPropertyOptional({ type: [CoachAssignmentDto], description: 'List of coaches to assign' }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => CoachAssignmentDto) coaches?: CoachAssignmentDto[];
|
||||||
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
@ApiPropertyOptional({ example: false, description: 'Exclude from public search (reserved for packages)' }) @IsOptional() isPackageOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export class TicketsController {
|
|||||||
@ApiQuery({ name: 'originStationId', required: false })
|
@ApiQuery({ name: 'originStationId', required: false })
|
||||||
@ApiQuery({ name: 'destinationStationId', required: false })
|
@ApiQuery({ name: 'destinationStationId', required: false })
|
||||||
@ApiQuery({ name: 'arrivalDate', required: false })
|
@ApiQuery({ name: 'arrivalDate', required: false })
|
||||||
|
@ApiQuery({ name: 'departureDate', required: false })
|
||||||
@ApiQuery({ name: 'dateFrom', required: false })
|
@ApiQuery({ name: 'dateFrom', required: false })
|
||||||
@ApiQuery({ name: 'dateTo', required: false })
|
@ApiQuery({ name: 'dateTo', required: false })
|
||||||
@ApiQuery({ name: 'coachId', required: false })
|
@ApiQuery({ name: 'coachId', required: false })
|
||||||
@@ -64,6 +65,7 @@ export class TicketsController {
|
|||||||
@Query('originStationId') originStationId?: string,
|
@Query('originStationId') originStationId?: string,
|
||||||
@Query('destinationStationId') destinationStationId?: string,
|
@Query('destinationStationId') destinationStationId?: string,
|
||||||
@Query('arrivalDate') arrivalDate?: string,
|
@Query('arrivalDate') arrivalDate?: string,
|
||||||
|
@Query('departureDate') departureDate?: string,
|
||||||
@Query('dateFrom') dateFrom?: string,
|
@Query('dateFrom') dateFrom?: string,
|
||||||
@Query('dateTo') dateTo?: string,
|
@Query('dateTo') dateTo?: string,
|
||||||
@Query('coachId') coachId?: string,
|
@Query('coachId') coachId?: string,
|
||||||
@@ -76,6 +78,7 @@ export class TicketsController {
|
|||||||
originStationId,
|
originStationId,
|
||||||
destinationStationId,
|
destinationStationId,
|
||||||
arrivalDate,
|
arrivalDate,
|
||||||
|
departureDate,
|
||||||
dateFrom,
|
dateFrom,
|
||||||
dateTo,
|
dateTo,
|
||||||
coachId,
|
coachId,
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export class TicketsService {
|
|||||||
@InjectDataSource() private readonly dataSource: DataSource,
|
@InjectDataSource() private readonly dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
async listTickets(filters: { search?: string; status?: string; originStationId?: string; destinationStationId?: string; departureDate?: string; arrivalDate?: string; dateFrom?: string; dateTo?: string; coachId?: string; skip: number; take: number }) {
|
||||||
const where: any = {};
|
const where: any = {};
|
||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
@@ -41,10 +41,16 @@ export class TicketsService {
|
|||||||
where.status = filters.status;
|
where.status = filters.status;
|
||||||
}
|
}
|
||||||
if (filters.originStationId) {
|
if (filters.originStationId) {
|
||||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, originStationId: filters.originStationId } };
|
where.booking = { ...where.booking, originStationId: filters.originStationId };
|
||||||
}
|
}
|
||||||
if (filters.destinationStationId) {
|
if (filters.destinationStationId) {
|
||||||
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, destinationStationId: filters.destinationStationId } };
|
where.booking = { ...where.booking, destinationStationId: filters.destinationStationId };
|
||||||
|
}
|
||||||
|
if (filters.departureDate) {
|
||||||
|
const start = new Date(filters.departureDate);
|
||||||
|
const end = new Date(filters.departureDate);
|
||||||
|
end.setDate(end.getDate() + 1);
|
||||||
|
where.booking = { ...where.booking, schedule: { ...where.booking?.schedule, departureAt: { gte: start, lt: end } } };
|
||||||
}
|
}
|
||||||
if (filters.arrivalDate) {
|
if (filters.arrivalDate) {
|
||||||
const start = new Date(filters.arrivalDate);
|
const start = new Date(filters.arrivalDate);
|
||||||
@@ -70,7 +76,7 @@ export class TicketsService {
|
|||||||
include: {
|
include: {
|
||||||
booking: {
|
booking: {
|
||||||
include: {
|
include: {
|
||||||
schedule: { include: { originStation: true, destinationStation: true, train: true } },
|
schedule: { include: { originStation: true, destinationStation: true, train: true, stopTimes: { include: { station: true } } } },
|
||||||
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
returnSchedule: { include: { originStation: true, destinationStation: true } },
|
||||||
passenger: { include: { travelerProfiles: true } },
|
passenger: { include: { travelerProfiles: true } },
|
||||||
seats: { include: { seat: { include: { coach: true } } } },
|
seats: { include: { seat: { include: { coach: true } } } },
|
||||||
@@ -146,6 +152,18 @@ export class TicketsService {
|
|||||||
contactPhone: t.booking?.contactPhone,
|
contactPhone: t.booking?.contactPhone,
|
||||||
returnSchedule: t.booking?.returnSchedule ?? null,
|
returnSchedule: t.booking?.returnSchedule ?? null,
|
||||||
seats: t.booking?.seats ?? [],
|
seats: t.booking?.seats ?? [],
|
||||||
|
originStation: (() => {
|
||||||
|
const id = t.booking?.originStationId;
|
||||||
|
if (!id) return t.booking?.schedule?.originStation ?? null;
|
||||||
|
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||||
|
return stop?.station ?? t.booking?.schedule?.originStation ?? null;
|
||||||
|
})(),
|
||||||
|
destinationStation: (() => {
|
||||||
|
const id = t.booking?.destinationStationId;
|
||||||
|
if (!id) return t.booking?.schedule?.destinationStation ?? null;
|
||||||
|
const stop = t.booking?.schedule?.stopTimes?.find((st: any) => st.stationId === id);
|
||||||
|
return stop?.station ?? t.booking?.schedule?.destinationStation ?? null;
|
||||||
|
})(),
|
||||||
},
|
},
|
||||||
schedule: t.booking?.schedule,
|
schedule: t.booking?.schedule,
|
||||||
seat: t.seat ? {
|
seat: t.seat ? {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DashboardLayout from '../../dashboard/layout';
|
||||||
|
|
||||||
|
export default function PassengersLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
'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';
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold tabular-nums mt-1">{value}</p>
|
||||||
|
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PassengersReportPage() {
|
||||||
|
const [scheduleId, setScheduleId] = useState('');
|
||||||
|
|
||||||
|
const { data: schedules = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ['schedules-list'],
|
||||||
|
queryFn: () => apiClient.get('/schedules'),
|
||||||
|
select: (d: any) => d?.items ?? (Array.isArray(d) ? d : []),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isFetching } = useQuery({
|
||||||
|
queryKey: ['occupancy-report', scheduleId],
|
||||||
|
queryFn: () => apiClient.get(`/reports/passengers?scheduleId=${scheduleId}`),
|
||||||
|
enabled: !!scheduleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = data as any;
|
||||||
|
|
||||||
|
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 url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `occupancy-${scheduleId}-${new Date().toISOString().split('T')[0]}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
};
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule Selector */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex flex-wrap items-end gap-4">
|
||||||
|
<div className="flex-1 min-w-64">
|
||||||
|
<label className="label">Schedule</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={scheduleId}
|
||||||
|
onChange={(e) => setScheduleId(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">— Select a schedule —</option>
|
||||||
|
{schedules.map((s: any) => (
|
||||||
|
<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() : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{isFetching && <p className="text-sm text-muted-foreground self-center">Loading…</p>}
|
||||||
|
{report && (
|
||||||
|
<ActionButton icon={Download} variant="secondary" onClick={doExport}>
|
||||||
|
Export CSV
|
||||||
|
</ActionButton>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isFetching && (
|
||||||
|
<div className="card py-12 text-center text-muted-foreground">Loading passengers data…</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report && (
|
||||||
|
<>
|
||||||
|
{/* Schedule Info */}
|
||||||
|
<div className="card flex items-center gap-4">
|
||||||
|
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
|
||||||
|
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
<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)}
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<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>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.byCoach.map((c: any, i: number) => (
|
||||||
|
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="py-1.5 font-mono font-semibold">Coach {c.coachNumber}</td>
|
||||||
|
<td className="py-1.5 text-muted-foreground">{c.coachType}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No coach data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By Class */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Occupancy by Class</h3>
|
||||||
|
{report.byClass.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<BarChart data={report.byClass}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="className" tick={{ fontSize: 11 }} />
|
||||||
|
<YAxis domain={[0, 100]} tickFormatter={(v) => `${v}%`} tick={{ fontSize: 11 }} />
|
||||||
|
<Tooltip formatter={(v: number) => [`${v}%`, 'Occupancy']} />
|
||||||
|
<Bar dataKey="occupancyRate" radius={[3, 3, 0, 0]}>
|
||||||
|
{report.byClass.map((_: any, i: number) => (
|
||||||
|
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
<table className="w-full mt-3 text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-muted-foreground border-b border-border">
|
||||||
|
<th className="text-left py-1.5 font-medium">Class</th>
|
||||||
|
<th className="text-right py-1.5 font-medium">Booked</th>
|
||||||
|
<th className="text-right py-1.5 font-medium">Total</th>
|
||||||
|
<th className="text-right py-1.5 font-medium">Rate</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.byClass.map((c: any, i: number) => (
|
||||||
|
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="py-1.5 font-medium">{c.className}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums">{c.booked}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums text-muted-foreground">{c.totalSeats}</td>
|
||||||
|
<td className="py-1.5 text-right tabular-nums font-semibold">{c.occupancyRate}%</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No class data</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* By Origin */}
|
||||||
|
<div className="card">
|
||||||
|
<h3 className="text-base font-semibold mb-4">Passengers by Boarding Station</h3>
|
||||||
|
{report.byOrigin.length > 0 ? (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-muted-foreground border-b border-border">
|
||||||
|
<th className="text-left py-1.5 font-medium">Station</th>
|
||||||
|
<th className="text-right py-1.5 font-medium">Passengers</th>
|
||||||
|
<th className="text-right py-1.5 font-medium">Share</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{report.byOrigin.map((o: any, i: number) => {
|
||||||
|
const pct = report.summary.totalPassengers > 0
|
||||||
|
? ((o.passengers / report.summary.totalPassengers) * 100).toFixed(1)
|
||||||
|
: '0';
|
||||||
|
return (
|
||||||
|
<tr key={i} className="border-b border-border/50 last:border-0">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No boarding station data</p>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!scheduleId && (
|
||||||
|
<div className="card py-16 text-center text-muted-foreground">
|
||||||
|
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||||
|
<p>Select a schedule above to load the occupancy report</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,7 +15,7 @@ import { formatDateTime, formatCurrency, formatDateTimeShort } from '@/lib/utils
|
|||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
export default function TicketsPage() {
|
export default function TicketsPage() {
|
||||||
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
const [filters, setFilters] = useState({ search: '', status: '', originStationId: '', destinationStationId: '', departureDate: '', arrivalDate: '', dateFrom: '', dateTo: '', coachId: '' });
|
||||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||||
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
const [ticketToDelete, setTicketToDelete] = useState<any>(null);
|
||||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||||
@@ -71,6 +71,7 @@ export default function TicketsPage() {
|
|||||||
status: filters.status || undefined,
|
status: filters.status || undefined,
|
||||||
originStationId: filters.originStationId || undefined,
|
originStationId: filters.originStationId || undefined,
|
||||||
destinationStationId: filters.destinationStationId || undefined,
|
destinationStationId: filters.destinationStationId || undefined,
|
||||||
|
departureDate: filters.departureDate || undefined,
|
||||||
arrivalDate: filters.arrivalDate || undefined,
|
arrivalDate: filters.arrivalDate || undefined,
|
||||||
dateFrom: filters.dateFrom || undefined,
|
dateFrom: filters.dateFrom || undefined,
|
||||||
dateTo: filters.dateTo || undefined,
|
dateTo: filters.dateTo || undefined,
|
||||||
@@ -358,11 +359,13 @@ export default function TicketsPage() {
|
|||||||
render: (ticket: any) => {
|
render: (ticket: any) => {
|
||||||
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
const isRoundTrip = ticket.booking?.bookingType === 'ROUND_TRIP' || ticket.booking?.bookingType === 'ROUND_TRIP_TRANSIT';
|
||||||
const returnDeparture = ticket.booking?.returnSchedule?.departureAt;
|
const returnDeparture = ticket.booking?.returnSchedule?.departureAt;
|
||||||
|
const origin = ticket.booking?.originStation?.name || ticket.schedule?.originStation?.name || 'N/A';
|
||||||
|
const destination = ticket.booking?.destinationStation?.name || ticket.schedule?.destinationStation?.name || 'N/A';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
{ticket.schedule?.originStation?.name || 'N/A'} → {ticket.schedule?.destinationStation?.name || 'N/A'}
|
{origin} → {destination}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-xs text-muted-foreground">
|
||||||
{!isRoundTrip ? (
|
{!isRoundTrip ? (
|
||||||
@@ -551,7 +554,7 @@ export default function TicketsPage() {
|
|||||||
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
|
Error loading tickets: {error instanceof Error ? error.message : 'Unknown error'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Search</label>
|
<label className="label">Search</label>
|
||||||
<input
|
<input
|
||||||
@@ -589,14 +592,27 @@ export default function TicketsPage() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Arrival Date</label>
|
<label className="label">Departure Date</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
className="input"
|
className="input"
|
||||||
value={filters.arrivalDate}
|
value={filters.departureDate}
|
||||||
onChange={(e) => setFilters({ ...filters, arrivalDate: e.target.value })}
|
onChange={(e) => setFilters({ ...filters, departureDate: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Coach</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={filters.coachId}
|
||||||
|
onChange={(e) => setFilters({ ...filters, coachId: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="">All Coaches</option>
|
||||||
|
{(Array.isArray(coachesData) ? coachesData : (coachesData as any)?.data || []).map((coach: any) => (
|
||||||
|
<option key={coach.id} value={coach.id}>{coach.number}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
|
<button type="button" className="input w-full px-4 text-sm font-medium text-primary border-primary/40"
|
||||||
onClick={() => setShowExtraFilters(v => !v)}>
|
onClick={() => setShowExtraFilters(v => !v)}>
|
||||||
@@ -605,7 +621,7 @@ export default function TicketsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showExtraFilters && (
|
{showExtraFilters && (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3 mt-3">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mt-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Status</label>
|
<label className="label">Status</label>
|
||||||
<select
|
<select
|
||||||
@@ -619,19 +635,6 @@ export default function TicketsPage() {
|
|||||||
<option value="CANCELLED">Cancelled</option>
|
<option value="CANCELLED">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="label">Coach</label>
|
|
||||||
<select
|
|
||||||
className="input"
|
|
||||||
value={filters.coachId}
|
|
||||||
onChange={(e) => setFilters({ ...filters, coachId: e.target.value })}
|
|
||||||
>
|
|
||||||
<option value="">All Coaches</option>
|
|
||||||
{(Array.isArray(coachesData) ? coachesData : (coachesData as any)?.data || []).map((coach: any) => (
|
|
||||||
<option key={coach.id} value={coach.id}>{coach.number}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Issued From</label>
|
<label className="label">Issued From</label>
|
||||||
<input type="date" className="input" value={filters.dateFrom}
|
<input type="date" className="input" value={filters.dateFrom}
|
||||||
@@ -794,8 +797,8 @@ export default function TicketsPage() {
|
|||||||
<section>
|
<section>
|
||||||
<SectionHeader title="Trip Information" />
|
<SectionHeader title="Trip Information" />
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<Field label="Origin" value={t.schedule?.originStation?.name} />
|
<Field label="Origin" value={t.booking?.originStation?.name || t.schedule?.originStation?.name} />
|
||||||
<Field label="Destination" value={t.schedule?.destinationStation?.name} />
|
<Field label="Destination" value={t.booking?.destinationStation?.name || t.schedule?.destinationStation?.name} />
|
||||||
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
|
<Field label="Departure" value={t.schedule?.departureAt ? formatDateTime(t.schedule.departureAt) : ''} />
|
||||||
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
|
<Field label="Arrival" value={t.schedule?.arrivalAt ? formatDateTime(t.schedule.arrivalAt) : ''} />
|
||||||
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />
|
<Field label="Train" value={t.schedule?.train?.name || t.schedule?.train?.number} />
|
||||||
|
|||||||
@@ -121,8 +121,9 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
title: 'Analytics & Reports',
|
title: 'Analytics & Reports',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
||||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||||
|
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
|
||||||
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user