From c62491b2364cd808d95f1a9dfe7bf0671f2df8c6 Mon Sep 17 00:00:00 2001 From: Stephanos A Date: Mon, 20 Jul 2026 00:37:10 +0300 Subject: [PATCH] Reports (overall, seats report, passenger export) updates --- .../src/modules/reports/reports.controller.ts | 6 + .../src/modules/reports/reports.service.ts | 95 ++++ .../src/app/reports/overall/layout.tsx | 5 + .../src/app/reports/overall/page.tsx | 517 +++++++++++++++++ .../backoffice/src/app/reports/page.tsx | 516 +---------------- .../src/app/reports/passengers/page.tsx | 44 +- .../backoffice/src/app/reports/seats/page.tsx | 530 ++++++++---------- .../src/components/layout/Sidebar.tsx | 2 +- 8 files changed, 890 insertions(+), 825 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/overall/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 74bad3514..4f2536e24 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -30,6 +30,12 @@ export class ReportsController { return this.service.getPassengerList(scheduleId); } + @Get('seats') + @ApiOperation({ summary: 'Seat status report for a specific schedule' }) + getSeatStatusReport(@Query('scheduleId') scheduleId: string) { + return this.service.getSeatStatusReport(scheduleId); + } + @Get('passengers') @ApiOperation({ summary: 'Passengers report for a specific schedule' }) getOccupancyReport(@Query('scheduleId') scheduleId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index b10d6963f..bced68fbd 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -378,6 +378,101 @@ export class ReportsService { })); } + async getSeatStatusReport(scheduleId: string) { + // Booked seats — exclude dining coaches + const bookingSeats = await this.prisma.bookingSeat.findMany({ + where: { + leg: 1, + booking: { scheduleId, status: { in: ['CONFIRMED', 'BOARDED', 'PENDING_PAYMENT'] } }, + seat: { coach: { coachType: { type: { not: 'dining' } } } }, + }, + include: { + booking: { + select: { + bookingRef: true, + status: true, + totalMinor: true, + currency: true, + createdAt: true, + paymentIntent: { select: { status: true } }, + }, + }, + seat: { + select: { + seatNumber: true, + bedPosition: true, + coach: { + select: { + number: true, + coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } }, + }, + }, + }, + }, + }, + orderBy: [{ seat: { coach: { number: 'asc' } } }, { seat: { seatNumber: 'asc' } }], + }); + + // Manually blocked seats for this schedule — exclude MAINTENANCE entries + const blocks = await this.prisma.seatBlock.findMany({ + where: { + scheduleId, + NOT: { reason: { startsWith: 'MAINTENANCE:' } }, + }, + include: { + seat: { + select: { + seatNumber: true, + bedPosition: true, + coach: { + select: { + number: true, + coachType: { select: { name: true, type: true, seatClasses: { select: { name: true, bedPosition: true } } } }, + }, + }, + }, + }, + }, + orderBy: { blockedAt: 'desc' }, + }); + + const resolveSeatClass = (seat: any): string | null => { + const classes = seat?.coach?.coachType?.seatClasses ?? []; + const matched = seat?.bedPosition + ? classes.find((sc: any) => sc.bedPosition?.toLowerCase() === seat.bedPosition.toLowerCase()) + : null; + return (matched ?? classes[0])?.name ?? seat?.coach?.coachType?.name ?? null; + }; + + return { + bookedSeats: bookingSeats.map(bs => ({ + bookingRef: bs.booking.bookingRef, + passengerName: bs.passengerName, + passengerCategory: bs.passengerCategory, + coachNumber: bs.seat?.coach?.number ?? null, + seatNumber: bs.seat?.seatNumber ?? null, + seatClassName: resolveSeatClass(bs.seat), + fareMinor: bs.fareMinor, + currency: bs.booking.currency ?? 'ETB', + bookingStatus: bs.booking.status, + paymentStatus: bs.booking.paymentIntent?.status ?? 'PENDING', + bookedAt: bs.booking.createdAt, + })), + blockedSeats: blocks + .filter(b => b.seat?.coach?.coachType?.type !== 'dining') + .map(b => ({ + id: b.id, + coachNumber: b.seat?.coach?.number ?? null, + seatNumber: b.seat?.seatNumber ?? null, + seatClassName: resolveSeatClass(b.seat), + reason: b.reason, + blockedBy: b.blockedBy, + blockedAt: b.blockedAt, + unblockAt: b.unblockAt, + })), + }; + } + async getReport(reportId: string) { return this.prisma.operationalReport.findUnique({ where: { id: reportId } }); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/overall/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/overall/layout.tsx new file mode 100644 index 000000000..5dd5937b8 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/overall/layout.tsx @@ -0,0 +1,5 @@ +import DashboardLayout from '../../dashboard/layout'; + +export default function OverallReportLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx new file mode 100644 index 000000000..e715fbd71 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/overall/page.tsx @@ -0,0 +1,517 @@ +'use client'; + +import { useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { Download, TrendingUp, BookOpen, Banknote, Ticket } from 'lucide-react'; +import { + LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, + Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, +} from 'recharts'; +import { bookingsApi } from '@/lib/api'; +import { dashboardApi } from '@/lib/api/dashboard'; +import { apiClient } from '@/lib/api-client'; +import { formatCurrency } from '@/lib/utils'; +import ActionButton from '@/components/ui/ActionButton'; +import Modal from '@/components/ui/Modal'; + +const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']; + +function esc(s: string) { + return String(s).replace(/&/g, '&').replace(//g, '>'); +} + +export default function ReportsPage() { + const [dateRange, setDateRange] = useState('30'); + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [exportModalOpen, setExportModalOpen] = useState(false); + const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); + + const getDateRange = () => { + const end = new Date(); + end.setHours(23, 59, 59, 999); + const start = new Date(); + switch (dateRange) { + case '7': start.setDate(end.getDate() - 7); break; + case '30': start.setDate(end.getDate() - 30); break; + case '90': start.setDate(end.getDate() - 90); break; + default: + if (startDate && endDate) return { startDate, endDate }; + } + return { + startDate: start.toISOString().split('T')[0], + endDate: end.toISOString().split('T')[0], + }; + }; + + const dates = getDateRange(); + + // Confirmed-ticket revenue — same source as dashboard + const { data: stats, isLoading: statsLoading } = useQuery({ + queryKey: ['backoffice-stats'], + queryFn: dashboardApi.getBackofficeStats, + staleTime: 60000, + }); + + const { data: exchangeRates = [] } = useQuery({ + 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); + return r ? 1 / r.rate : null; + }; + + const calcGrand = (rows: { currency: string; totalMinor: number }[]) => + rows.reduce((sum, { currency, totalMinor }) => { + const rate = toEtbRate(currency); + return rate !== null ? sum + Math.round(totalMinor * rate) : sum; + }, 0); + + const normalRows = stats?.revenueByCurrency ?? []; + const packageRows = stats?.packageRevenueByCurrency ?? []; + const normalGrand = calcGrand(normalRows); + const packageGrand = calcGrand(packageRows); + const overallGrand = normalGrand + packageGrand; + + // Bookings for charts / status distribution + const { data: bookingsData, isLoading: bookingsLoading } = useQuery({ + queryKey: ['all-bookings'], + queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), + }); + + const isLoading = statsLoading || bookingsLoading; + + const allBookings: any[] = Array.isArray(bookingsData?.items) ? bookingsData.items : []; + + const bookings = allBookings.filter((b: any) => { + const d = new Date(b.createdAt).toISOString().split('T')[0]; + return d >= dates.startDate && d <= dates.endDate; + }); + + const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED'); + + const byDate = confirmedBookings.reduce((acc: Record, b: any) => { + const date = new Date(b.createdAt).toISOString().split('T')[0]; + if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 }; + acc[date].totalMinor += b.totalMinor || 0; + acc[date].count += 1; + return acc; + }, {}); + + const chartData = Object.entries(byDate) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, d]: [string, any]) => ({ + date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), + revenue: (d.totalMinor || 0) / 100, + bookings: d.count || 0, + })); + + const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0; + + const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings']; + + const doExport = () => { + if (!chartData.length) { alert('No data to export'); return; } + const rows = chartData.map(r => [r.date, String(Math.round(r.revenue)), String(r.bookings)]); + const dateStr = new Date().toISOString().split('T')[0]; + + if (exportFormat === 'pdf') { + const w = window.open('', '_blank')!; + const thead = REPORT_COLS.map(h => `${esc(h)}`).join(''); + const tbody = rows.map(r => `${r.map(v => `${esc(v)}`).join('')}`).join(''); + w.document.write( + `Revenue Report` + + `` + + `

Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}

` + + `

Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` + + `Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` + + `Tickets: ${esc(String(stats?.totalTickets ?? 0))}

` + + `${thead}${tbody}
` + ); + w.document.close(); w.print(); + } else if (exportFormat === 'excel') { + const tsv = [REPORT_COLS.join('\t'), ...rows.map(r => r.join('\t'))].join('\n'); + const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url); + } else { + const csv = [REPORT_COLS.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${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 = `revenue-report-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url); + } + setExportModalOpen(false); + }; + + const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => { + const rate = toEtbRate(currency); + const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null; + return ( +
+
+ + {currency} +
+ + {formatCurrency(totalMinor, currency)} + {currency !== 'ETB' && etbMinor !== null && ( + + ({formatCurrency(etbMinor, 'ETB')}) + + )} + +
+ ); + }; + + return ( +
+
+

Reports & Analytics

+

Revenue figures reflect confirmed tickets only

+
+ + {/* Date Range Selector */} +
+
+
+ + +
+ {dateRange === 'custom' && ( + <> +
+ + setStartDate(e.target.value)} disabled={isLoading} /> +
+
+ + setEndDate(e.target.value)} disabled={isLoading} /> +
+ + )} + setExportModalOpen(true)}> + Export + +
+ {isLoading &&

Loading…

} +
+ + {/* Key Metrics */} +
+ {/* Total Revenue */} +
+
+

Total Revenue

+
+ +
+
+

+ {statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')} +

+
+
+ Regular + {statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')} +
+
+ Package + {statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')} +
+
+
+ + {/* Total Bookings */} +
+
+

Total Bookings

+
+ +
+
+

+ {statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()} +

+
+
+ Regular + {statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()} +
+
+ Package + {statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()} +
+
+
+ + {/* Total Tickets */} +
+
+

Total Tickets

+
+ +
+
+

+ {statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()} +

+
+
+ Regular + {statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()} +
+
+ Package + {statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()} +
+
+
+ + {/* Avg Daily Revenue */} +
+
+

Avg. Daily Revenue

+
+ +
+
+

+ {isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')} +

+

+ Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range +

+
+
+ + {/* Revenue Breakdown by Currency */} +
+

+ Revenue Breakdown — Confirmed Tickets +

+ {statsLoading ? ( +

Loading…

+ ) : !normalRows.length && !packageRows.length ? ( +

No revenue data yet.

+ ) : ( +
+ {/* Regular */} +
+
+ Regular + + {(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets + +
+ {normalRows.length === 0 + ?

No revenue yet

+ : normalRows.map(renderCurrencyRow)} + {normalRows.length > 0 && ( +
+ Subtotal + {formatCurrency(normalGrand, 'ETB')} +
+ )} +
+ + {/* Package */} +
+
+ Package + + {(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets + +
+ {packageRows.length === 0 + ?

No revenue yet

+ : packageRows.map(renderCurrencyRow)} + {packageRows.length > 0 && ( +
+ Subtotal + {formatCurrency(packageGrand, 'ETB')} +
+ )} +
+
+ )} + {!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && ( +
+ Grand Total (ETB equivalent) + + {formatCurrency(overallGrand, 'ETB')} + +
+ )} +
+ + {/* Charts */} +
+ {/* Revenue Trend */} +
+

+ Revenue Trend{' '} + (confirmed, ETB) +

+ {chartData.length > 0 ? ( + + + + + + [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} /> + + + + + ) : ( +
+ No data for selected range +
+ )} +
+ + {/* Daily Confirmed Bookings */} +
+

Daily Confirmed Bookings

+ {chartData.length > 0 ? ( + + + + + + + + + + ) : ( +
+ No data for selected range +
+ )} +
+ + {/* Booking Status Distribution */} +
+

Booking Status Distribution

+ {bookings.length > 0 ? ( + + + b.status === 'CONFIRMED').length }, + { name: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length }, + { name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length }, + { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length }, + ].filter(d => d.value > 0)} + cx="50%" cy="50%" + labelLine={false} + label={({ name, value }) => `${name}: ${value}`} + outerRadius={100} + dataKey="value" + > + {STATUS_COLORS.map((color, idx) => )} + + + + + ) : ( +
+ No data for selected range +
+ )} +
+ + {/* Payment Methods */} +
+

Payment Methods

+ {bookings.length > 0 ? ( +
+ {(Object.entries( + bookings.reduce((acc: Record, b: any) => { + const method = b.paymentIntent?.method || 'Unknown'; + acc[method] = (acc[method] || 0) + 1; + return acc; + }, {} as Record) + ) as [string, number][]) + .sort(([, a], [, b]) => b - a) + .slice(0, 6) + .map(([method, count]) => { + const pct = bookings.length > 0 ? Math.round((count / bookings.length) * 100) : 0; + return ( +
+ {method.toLowerCase().replace(/_/g, ' ')} +
+
+
+ {count} +
+ ); + })} +
+ ) : ( +
+ No data for selected range +
+ )} +
+
+ + {/* Summary */} +
+

Summary

+
+ {[ + { label: 'Active Days', value: chartData.length, fromStats: false }, + { label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false }, + { label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false }, + { label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false }, + { label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true }, + { label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true }, + ].map(({ label, value, fromStats }) => ( +
+

{label}

+

+ {fromStats && statsLoading ? '—' : value.toLocaleString()} +

+
+ ))} +
+
+ + {/* Export Modal */} + setExportModalOpen(false)} title="Export Revenue Report" size="sm"> +
+

+ Exports daily confirmed-booking revenue for the selected date range. Cancelled and refunded bookings are excluded. +

+
+

Format

+
+ {(['csv', 'excel', 'pdf'] as const).map(fmt => ( + + ))} +
+
+
+ setExportModalOpen(false)}>Cancel + Export +
+
+
+
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx index e715fbd71..cb47f6c07 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/page.tsx @@ -1,517 +1,5 @@ -'use client'; - -import { useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { Download, TrendingUp, BookOpen, Banknote, Ticket } from 'lucide-react'; -import { - LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, - Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell, -} from 'recharts'; -import { bookingsApi } from '@/lib/api'; -import { dashboardApi } from '@/lib/api/dashboard'; -import { apiClient } from '@/lib/api-client'; -import { formatCurrency } from '@/lib/utils'; -import ActionButton from '@/components/ui/ActionButton'; -import Modal from '@/components/ui/Modal'; - -const STATUS_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']; - -function esc(s: string) { - return String(s).replace(/&/g, '&').replace(//g, '>'); -} +import { redirect } from 'next/navigation'; export default function ReportsPage() { - const [dateRange, setDateRange] = useState('30'); - const [startDate, setStartDate] = useState(''); - const [endDate, setEndDate] = useState(''); - const [exportModalOpen, setExportModalOpen] = useState(false); - const [exportFormat, setExportFormat] = useState<'csv' | 'excel' | 'pdf'>('csv'); - - const getDateRange = () => { - const end = new Date(); - end.setHours(23, 59, 59, 999); - const start = new Date(); - switch (dateRange) { - case '7': start.setDate(end.getDate() - 7); break; - case '30': start.setDate(end.getDate() - 30); break; - case '90': start.setDate(end.getDate() - 90); break; - default: - if (startDate && endDate) return { startDate, endDate }; - } - return { - startDate: start.toISOString().split('T')[0], - endDate: end.toISOString().split('T')[0], - }; - }; - - const dates = getDateRange(); - - // Confirmed-ticket revenue — same source as dashboard - const { data: stats, isLoading: statsLoading } = useQuery({ - queryKey: ['backoffice-stats'], - queryFn: dashboardApi.getBackofficeStats, - staleTime: 60000, - }); - - const { data: exchangeRates = [] } = useQuery({ - 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); - return r ? 1 / r.rate : null; - }; - - const calcGrand = (rows: { currency: string; totalMinor: number }[]) => - rows.reduce((sum, { currency, totalMinor }) => { - const rate = toEtbRate(currency); - return rate !== null ? sum + Math.round(totalMinor * rate) : sum; - }, 0); - - const normalRows = stats?.revenueByCurrency ?? []; - const packageRows = stats?.packageRevenueByCurrency ?? []; - const normalGrand = calcGrand(normalRows); - const packageGrand = calcGrand(packageRows); - const overallGrand = normalGrand + packageGrand; - - // Bookings for charts / status distribution - const { data: bookingsData, isLoading: bookingsLoading } = useQuery({ - queryKey: ['all-bookings'], - queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), - }); - - const isLoading = statsLoading || bookingsLoading; - - const allBookings: any[] = Array.isArray(bookingsData?.items) ? bookingsData.items : []; - - const bookings = allBookings.filter((b: any) => { - const d = new Date(b.createdAt).toISOString().split('T')[0]; - return d >= dates.startDate && d <= dates.endDate; - }); - - const confirmedBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED'); - - const byDate = confirmedBookings.reduce((acc: Record, b: any) => { - const date = new Date(b.createdAt).toISOString().split('T')[0]; - if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 }; - acc[date].totalMinor += b.totalMinor || 0; - acc[date].count += 1; - return acc; - }, {}); - - const chartData = Object.entries(byDate) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([date, d]: [string, any]) => ({ - date: new Date(date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }), - revenue: (d.totalMinor || 0) / 100, - bookings: d.count || 0, - })); - - const avgDailyRevenue = chartData.length > 0 ? Math.round(overallGrand / 100 / chartData.length) : 0; - - const REPORT_COLS = ['Date', 'Revenue (ETB)', 'Confirmed Bookings']; - - const doExport = () => { - if (!chartData.length) { alert('No data to export'); return; } - const rows = chartData.map(r => [r.date, String(Math.round(r.revenue)), String(r.bookings)]); - const dateStr = new Date().toISOString().split('T')[0]; - - if (exportFormat === 'pdf') { - const w = window.open('', '_blank')!; - const thead = REPORT_COLS.map(h => `${esc(h)}`).join(''); - const tbody = rows.map(r => `${r.map(v => `${esc(v)}`).join('')}`).join(''); - w.document.write( - `Revenue Report` + - `` + - `

Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}

` + - `

Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` + - `Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` + - `Tickets: ${esc(String(stats?.totalTickets ?? 0))}

` + - `${thead}${tbody}
` - ); - w.document.close(); w.print(); - } else if (exportFormat === 'excel') { - const tsv = [REPORT_COLS.join('\t'), ...rows.map(r => r.join('\t'))].join('\n'); - const blob = new Blob([tsv], { type: 'application/vnd.ms-excel' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); a.href = url; a.download = `revenue-report-${dateStr}.xls`; a.click(); URL.revokeObjectURL(url); - } else { - const csv = [REPORT_COLS.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${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 = `revenue-report-${dateStr}.csv`; a.click(); URL.revokeObjectURL(url); - } - setExportModalOpen(false); - }; - - const renderCurrencyRow = ({ currency, totalMinor }: { currency: string; totalMinor: number }) => { - const rate = toEtbRate(currency); - const etbMinor = rate !== null ? Math.round(totalMinor * rate) : null; - return ( -
-
- - {currency} -
- - {formatCurrency(totalMinor, currency)} - {currency !== 'ETB' && etbMinor !== null && ( - - ({formatCurrency(etbMinor, 'ETB')}) - - )} - -
- ); - }; - - return ( -
-
-

Reports & Analytics

-

Revenue figures reflect confirmed tickets only

-
- - {/* Date Range Selector */} -
-
-
- - -
- {dateRange === 'custom' && ( - <> -
- - setStartDate(e.target.value)} disabled={isLoading} /> -
-
- - setEndDate(e.target.value)} disabled={isLoading} /> -
- - )} - setExportModalOpen(true)}> - Export - -
- {isLoading &&

Loading…

} -
- - {/* Key Metrics */} -
- {/* Total Revenue */} -
-
-

Total Revenue

-
- -
-
-

- {statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')} -

-
-
- Regular - {statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')} -
-
- Package - {statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')} -
-
-
- - {/* Total Bookings */} -
-
-

Total Bookings

-
- -
-
-

- {statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()} -

-
-
- Regular - {statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()} -
-
- Package - {statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()} -
-
-
- - {/* Total Tickets */} -
-
-

Total Tickets

-
- -
-
-

- {statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()} -

-
-
- Regular - {statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()} -
-
- Package - {statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()} -
-
-
- - {/* Avg Daily Revenue */} -
-
-

Avg. Daily Revenue

-
- -
-
-

- {isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')} -

-

- Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range -

-
-
- - {/* Revenue Breakdown by Currency */} -
-

- Revenue Breakdown — Confirmed Tickets -

- {statsLoading ? ( -

Loading…

- ) : !normalRows.length && !packageRows.length ? ( -

No revenue data yet.

- ) : ( -
- {/* Regular */} -
-
- Regular - - {(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets - -
- {normalRows.length === 0 - ?

No revenue yet

- : normalRows.map(renderCurrencyRow)} - {normalRows.length > 0 && ( -
- Subtotal - {formatCurrency(normalGrand, 'ETB')} -
- )} -
- - {/* Package */} -
-
- Package - - {(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets - -
- {packageRows.length === 0 - ?

No revenue yet

- : packageRows.map(renderCurrencyRow)} - {packageRows.length > 0 && ( -
- Subtotal - {formatCurrency(packageGrand, 'ETB')} -
- )} -
-
- )} - {!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && ( -
- Grand Total (ETB equivalent) - - {formatCurrency(overallGrand, 'ETB')} - -
- )} -
- - {/* Charts */} -
- {/* Revenue Trend */} -
-

- Revenue Trend{' '} - (confirmed, ETB) -

- {chartData.length > 0 ? ( - - - - - - [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} /> - - - - - ) : ( -
- No data for selected range -
- )} -
- - {/* Daily Confirmed Bookings */} -
-

Daily Confirmed Bookings

- {chartData.length > 0 ? ( - - - - - - - - - - ) : ( -
- No data for selected range -
- )} -
- - {/* Booking Status Distribution */} -
-

Booking Status Distribution

- {bookings.length > 0 ? ( - - - b.status === 'CONFIRMED').length }, - { name: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length }, - { name: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length }, - { name: 'Other', value: bookings.filter((b: any) => !['CONFIRMED', 'BOARDED', 'CANCELLED'].includes(b.status)).length }, - ].filter(d => d.value > 0)} - cx="50%" cy="50%" - labelLine={false} - label={({ name, value }) => `${name}: ${value}`} - outerRadius={100} - dataKey="value" - > - {STATUS_COLORS.map((color, idx) => )} - - - - - ) : ( -
- No data for selected range -
- )} -
- - {/* Payment Methods */} -
-

Payment Methods

- {bookings.length > 0 ? ( -
- {(Object.entries( - bookings.reduce((acc: Record, b: any) => { - const method = b.paymentIntent?.method || 'Unknown'; - acc[method] = (acc[method] || 0) + 1; - return acc; - }, {} as Record) - ) as [string, number][]) - .sort(([, a], [, b]) => b - a) - .slice(0, 6) - .map(([method, count]) => { - const pct = bookings.length > 0 ? Math.round((count / bookings.length) * 100) : 0; - return ( -
- {method.toLowerCase().replace(/_/g, ' ')} -
-
-
- {count} -
- ); - })} -
- ) : ( -
- No data for selected range -
- )} -
-
- - {/* Summary */} -
-

Summary

-
- {[ - { label: 'Active Days', value: chartData.length, fromStats: false }, - { label: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length, fromStats: false }, - { label: 'Boarded', value: bookings.filter((b: any) => b.status === 'BOARDED').length, fromStats: false }, - { label: 'Cancelled', value: bookings.filter((b: any) => b.status === 'CANCELLED').length, fromStats: false }, - { label: 'Regular Bookings', value: stats?.totalNormalBookings ?? 0, fromStats: true }, - { label: 'Package Bookings', value: stats?.totalPackageBookings ?? 0, fromStats: true }, - ].map(({ label, value, fromStats }) => ( -
-

{label}

-

- {fromStats && statsLoading ? '—' : value.toLocaleString()} -

-
- ))} -
-
- - {/* Export Modal */} - setExportModalOpen(false)} title="Export Revenue Report" size="sm"> -
-

- Exports daily confirmed-booking revenue for the selected date range. Cancelled and refunded bookings are excluded. -

-
-

Format

-
- {(['csv', 'excel', 'pdf'] as const).map(fmt => ( - - ))} -
-
-
- setExportModalOpen(false)}>Cancel - Export -
-
-
-
- ); + redirect('/reports/overall'); } diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index baa561728..c30a62704 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -106,13 +106,19 @@ export default function PassengersReportPage() { const doExportList = () => { if (!passengerList.length) return; - const headers = ['Booking Ref', 'Name', 'Nationality', 'Coach · Seat', 'Trip', 'Amount Paid', 'Group Booking']; + const headers = ['Booking Ref', 'Name', 'Nationality', 'Passport Number', 'Coach', 'Seat', 'Class', 'Origin', 'Destination', `Amount (ETB)`, 'Currency', 'Group Booking']; const rows = [...passengerList].sort((a, b) => a.bookingRef.localeCompare(b.bookingRef)).map(p => [ - p.bookingRef, p.passengerName, - p.passportNumber ? `${p.passportCountry ?? ''} · ${p.passportNumber}` : (p.idDocumentNumber ?? ''), - p.coachNumber && p.seatLabel ? `${p.coachNumber} · ${p.seatLabel}` : (p.coachNumber ?? p.seatLabel ?? ''), - p.origin && p.destination ? `${p.origin} → ${p.destination}` : (p.origin ?? p.destination ?? ''), - `${(p.amountPaidMinor / 100).toFixed(2)} ${p.currency}`, + p.bookingRef, + p.passengerName, + p.nationality ?? '', + p.passportNumber ?? '', + p.coachNumber ?? '', + p.seatNumber ?? p.seatLabel ?? '', + p.seatClassName ?? '', + p.origin ?? '', + p.destination ?? '', + (p.amountPaidMinor / 100).toFixed(2), + p.currency, p.isGroupBooking ? 'Yes' : 'No', ].map(v => `"${String(v).replace(/"/g, '""')}"`)); downloadCsv([headers.join(','), ...rows.map(r => r.join(','))].join('\n'), `passengers-${scheduleId}.csv`); @@ -319,8 +325,12 @@ export default function PassengersReportPage() { Name Nationality - Coach · Seat - Trip + Passport + Coach + Seat + Class + Origin + Destination Amount Paid Booking Ref @@ -329,15 +339,13 @@ export default function PassengersReportPage() { {filteredList.map((p, i) => ( {p.passengerName} - - {[p.nationality, p.passportNumber].filter(Boolean).join(' · ') || '—'} - - - {[p.coachNumber, p.seatNumber ?? p.seatLabel, p.seatClassName].filter(Boolean).join(' · ') || '—'} - - - {p.origin && p.destination ? `${p.origin} → ${p.destination}` : (p.origin ?? p.destination ?? '—')} - + {p.nationality ?? '—'} + {p.passportNumber ?? '—'} + {p.coachNumber ?? '—'} + {p.seatNumber ?? p.seatLabel ?? '—'} + {p.seatClassName ?? '—'} + {p.origin ?? '—'} + {p.destination ?? '—'}
@@ -359,7 +367,7 @@ export default function PassengersReportPage() { ))} {filteredList.length === 0 && ( - No passengers found + No passengers found )} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index f56f26e8e..aeda748b5 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -1,342 +1,288 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState } 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 { Download, Armchair, CheckCircle, Clock, Ban } from 'lucide-react'; +import { apiClient } from '@/lib/api-client'; import Badge from '@/components/ui/Badge'; import ActionButton from '@/components/ui/ActionButton'; import { formatDateTime, formatCurrency } from '@/lib/utils'; -interface SeatRow { +interface ScheduleOption { id: string; label: string; } + +interface BookedSeatRow { bookingRef: string; passengerName: string; - seatNumber: string; - coachNumber: string; + passengerCategory: string; + coachNumber: string | null; + seatNumber: string | null; + seatClassName: string | null; fareMinor: number; currency: string; - paymentStatus: string; bookingStatus: string; + paymentStatus: string; bookedAt: string; - releaseAt: string | null; - scheduleOrigin: string; - scheduleDestination: string; - scheduleDeparture: string; } -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; - if (seat?.holdExpiresAt) return seat.holdExpiresAt; - if (booking.createdAt) { - return new Date(new Date(booking.createdAt).getTime() + HOLD_DURATION_MS).toISOString(); - } - return null; +interface BlockedSeatRow { + id: string; + coachNumber: string | null; + seatNumber: string | null; + seatClassName: string | null; + reason: string; + blockedBy: string; + blockedAt: string; + unblockAt: string | null; } -function isExpired(releaseAt: string | null): boolean { - if (!releaseAt) return false; - return new Date(releaseAt) < new Date(); +interface SeatStatusReport { + bookedSeats: BookedSeatRow[]; + blockedSeats: BlockedSeatRow[]; +} + +function csvEscape(v: string) { return `"${String(v).replace(/"/g, '""')}"`; } + +function downloadCsv(csv: string, filename: string) { + const blob = new Blob([csv], { type: 'text/csv' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); } export default function SeatStatusReportPage() { - const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL'); + const [scheduleId, setScheduleId] = useState(''); const [search, setSearch] = useState(''); + const [payFilter, setPayFilter] = useState('ALL'); - const { data: blockedSeats = [] } = useQuery({ - queryKey: ['blocked-seats'], - queryFn: () => seatsApi.getBlocked().then((r: any) => Array.isArray(r) ? r : r?.data ?? []), + const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery({ + queryKey: ['report-schedules'], + queryFn: () => apiClient.get('/reports/schedules'), + }); + const schedules = schedulesRaw ?? []; + + const { data, isLoading } = useQuery({ + queryKey: ['seat-status-report', scheduleId], + queryFn: () => apiClient.get(`/reports/seats?scheduleId=${scheduleId}`), + enabled: !!scheduleId, }); - const { data: bookingsData, isLoading } = useQuery({ - queryKey: ['seat-report-bookings'], - queryFn: () => bookingsApi.getAll({ pageSize: 1000 }), - }); + const bookedSeats = data?.bookedSeats ?? []; + const blockedSeats = data?.blockedSeats ?? []; - const rows: SeatRow[] = useMemo(() => { - const bookings: any[] = bookingsData?.items || []; - const result: SeatRow[] = []; - - for (const booking of bookings) { - if (booking.status === 'CANCELLED') continue; - const seats: any[] = booking.seats || []; - 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 || '—', - fareMinor: seat.fareMinor ?? 0, - 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 || '', - }); - } + const filteredBooked = bookedSeats.filter(r => { + const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'; + if (payFilter === 'PAID' && !isPaid) return false; + if (payFilter === 'UNPAID' && isPaid) return false; + if (search.trim()) { + const q = search.toLowerCase(); + return ( + r.bookingRef.toLowerCase().includes(q) || + r.passengerName.toLowerCase().includes(q) || + (r.seatNumber ?? '').toLowerCase().includes(q) || + (r.coachNumber ?? '').toLowerCase().includes(q) + ); } + return true; + }); - return result; - }, [bookingsData]); + const paidCount = bookedSeats.filter(r => r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED').length; - 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; - if (search) { - const q = search.toLowerCase(); - return ( - r.bookingRef.toLowerCase().includes(q) || - r.passengerName.toLowerCase().includes(q) || - r.seatNumber.toLowerCase().includes(q) || - r.coachNumber.toLowerCase().includes(q) - ); - } - return true; - }); - }, [rows, statusFilter, search]); + const doExportBooked = () => { + if (!filteredBooked.length) return; + const headers = ['Booking Ref', 'Passenger', 'Category', 'Coach', 'Seat', 'Class', 'Fare', 'Currency', 'Payment Status', 'Booking Status', 'Booked At']; + const rows = filteredBooked.map(r => [ + r.bookingRef, r.passengerName, r.passengerCategory, + r.coachNumber ?? '', r.seatNumber ?? '', r.seatClassName ?? '', + (r.fareMinor / 100).toFixed(2), r.currency, + r.paymentStatus, r.bookingStatus, + r.bookedAt ? formatDateTime(r.bookedAt) : '', + ].map(csvEscape)); + downloadCsv([headers.map(csvEscape).join(','), ...rows.map(r => r.join(','))].join('\n'), `booked-seats-${scheduleId}.csv`); + }; - const paidCount = rows.filter( - (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; } - const headers = [ - 'Booking Ref', 'Passenger', 'Seat', 'Coach', 'Fare', - 'Payment Status', 'Booking Status', 'Booked At', 'Release At', - 'Origin', 'Destination', 'Departure', - ]; - const csvRows = filtered.map((r) => [ - r.bookingRef, - r.passengerName, - r.seatNumber, - r.coachNumber, - formatCurrency(r.fareMinor, r.currency), - r.paymentStatus, - r.bookingStatus, - r.bookedAt ? formatDateTime(r.bookedAt) : '—', - r.releaseAt ? formatDateTime(r.releaseAt) : '—', - r.scheduleOrigin, - r.scheduleDestination, - 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' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `seat-status-report-${new Date().toISOString().split('T')[0]}.csv`; - a.click(); - URL.revokeObjectURL(url); + const doExportBlocked = () => { + if (!blockedSeats.length) return; + const headers = ['Coach', 'Seat', 'Class', 'Reason', 'Blocked By', 'Blocked At', 'Unblock At']; + const rows = blockedSeats.map(r => [ + r.coachNumber ?? '', r.seatNumber ?? '', r.seatClassName ?? '', + r.reason, r.blockedBy, + formatDateTime(r.blockedAt), + r.unblockAt ? formatDateTime(r.unblockAt) : '', + ].map(csvEscape)); + downloadCsv([headers.map(csvEscape).join(','), ...rows.map(r => r.join(','))].join('\n'), `blocked-seats-${scheduleId}.csv`); }; return (

Seat Status Report

-

- Track booked seats — paid vs unpaid, booking times, and hold release times -

+

Booked and manually blocked seats for a schedule

- {/* Summary Cards */} -
-
-
-
-

Paid Seats

-

- {paidCount} -

-

Payment confirmed

-
- -
-
- -
-
-
-

Unpaid Seats

-

- {unpaidCount} -

-

Awaiting payment

-
- -
-
- -
-
-
-

Expired Holds

-

- {expiredCount} -

-

Hold time passed, not paid

-
- -
-
- -
-
-
-

Blocked Seats

-

- {blockedSeats.length} -

-

Manually blocked

-
- -
-
-
- - {/* Filters */} + {/* Schedule selector */}
-
-
- - setSearch(e.target.value)} - /> -
-
- +
+
+
- - Export CSV -
- {isLoading &&

Loading...

} + {isLoading &&

Loading…

}
- {/* Table */} -
-
- - - - {[ - 'Booking Ref', - 'Passenger', - 'Seat / Coach', - 'Fare', - 'Payment', - 'Booked At', - 'Release At', - 'Route', - ].map((h) => ( - - ))} - - - - {filtered.map((row, i) => { - const isPaid = - row.paymentStatus === 'SUCCEEDED' || row.paymentStatus === 'COMPLETED'; - const expired = isExpired(row.releaseAt); - return ( - - - - - - - - - - - ); - })} - -
- {h} -
- {row.bookingRef} - {row.passengerName} - {row.seatNumber} - {row.coachNumber !== '—' && ( - · Coach {row.coachNumber} - )} - - {formatCurrency(row.fareMinor, row.currency)} - - - {isPaid ? 'PAID' : row.paymentStatus} - - - {row.bookedAt ? formatDateTime(row.bookedAt) : '—'} - - {isPaid ? ( - - — Paid - - ) : row.releaseAt ? ( - - {expired ? '⚠ ' : '⏱ '} - {formatDateTime(row.releaseAt)} - {expired && ' (expired)'} - - ) : ( - - )} - - {row.scheduleOrigin} → {row.scheduleDestination} - {row.scheduleDeparture && ( -
{formatDateTime(row.scheduleDeparture)}
- )} -
-
- {!isLoading && filtered.length === 0 && ( -
- -

No seats found

+ {data && ( + <> + {/* Summary cards */} +
+
+
+

Booked Seats

+
+
+

{bookedSeats.length}

+
+
+
+

Paid

+
+
+

{paidCount}

+

{bookedSeats.length - paidCount} unpaid

+
+
+
+

Blocked Seats

+
+
+

{blockedSeats.length}

+

Manually blocked

+
- )} -
+ + {/* Booked seats */} +
+
+

Booked Seats

+ setSearch(e.target.value)} + /> + + {filteredBooked.length > 0 && ( + Export CSV + )} +
+
+ + + + + + + + + + + + + + + + {filteredBooked.map((r, i) => { + const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED'; + return ( + + + + + + + + + + + + ); + })} + {filteredBooked.length === 0 && ( + + )} + +
Booking RefPassengerCoachSeatClassFarePaymentBooking StatusBooked At
{r.bookingRef}{r.passengerName}{r.coachNumber ?? '—'}{r.seatNumber ?? '—'}{r.seatClassName ?? '—'}{formatCurrency(r.fareMinor, r.currency)} + + {isPaid ? 'PAID' : r.paymentStatus} + + + {r.bookingStatus} + {r.bookedAt ? formatDateTime(r.bookedAt) : '—'}
No booked seats found
+
+
+ + {/* Blocked seats */} +
+
+

Manually Blocked Seats

+ {blockedSeats.length > 0 && ( + Export CSV + )} +
+
+ + + + + + + + + + + + + + {blockedSeats.map((r, i) => ( + + + + + + + + + + ))} + {blockedSeats.length === 0 && ( + + )} + +
CoachSeatClassReasonBlocked ByBlocked AtUnblock At
{r.coachNumber ?? '—'}{r.seatNumber ?? '—'}{r.seatClassName ?? '—'}{r.reason}{r.blockedBy}{formatDateTime(r.blockedAt)}{r.unblockAt ? formatDateTime(r.unblockAt) : '—'}
No manually blocked seats
+
+
+ + )} + + {!scheduleId && !isLoading && ( +
+ +

Select a schedule to view seat status

+
+ )}
); } diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 83c7271d0..c71e2dbce 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -119,7 +119,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [ { title: 'Analytics & Reports', items: [ - { name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view }, + { name: 'Overall', href: '/reports/overall', icon: BarChart3, 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 },