mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Reports (overall, seats report, passenger export) updates
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import DashboardLayout from '../../dashboard/layout';
|
||||
|
||||
export default function OverallReportLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -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, '<').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<any[]>({
|
||||
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<string, any>, 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 => `<th>${esc(h)}</th>`).join('');
|
||||
const tbody = rows.map(r => `<tr>${r.map(v => `<td>${esc(v)}</td>`).join('')}</tr>`).join('');
|
||||
w.document.write(
|
||||
`<!DOCTYPE html><html><head><title>Revenue Report</title>` +
|
||||
`<style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}` +
|
||||
`th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>` +
|
||||
`<h2>Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}</h2>` +
|
||||
`<p>Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` +
|
||||
`Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` +
|
||||
`Tickets: ${esc(String(stats?.totalTickets ?? 0))}</p>` +
|
||||
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></body></html>`
|
||||
);
|
||||
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 (
|
||||
<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">{currency}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{formatCurrency(totalMinor, currency)}
|
||||
{currency !== 'ETB' && etbMinor !== null && (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||
({formatCurrency(etbMinor, 'ETB')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
|
||||
<p className="text-muted-foreground mt-1">Revenue figures reflect confirmed tickets only</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<div className="card">
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="label">Date Range</label>
|
||||
<select className="input" value={dateRange} onChange={(e) => setDateRange(e.target.value)} disabled={isLoading}>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="90">Last 90 Days</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
{dateRange === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} disabled={isLoading} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} disabled={isLoading} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Revenue */}
|
||||
<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 Revenue</p>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
|
||||
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Bookings */}
|
||||
<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 Bookings</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Tickets */}
|
||||
<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 Tickets</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
||||
<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avg Daily Revenue */}
|
||||
<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">Avg. Daily Revenue</p>
|
||||
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
|
||||
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Breakdown by Currency */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
Revenue Breakdown — Confirmed Tickets
|
||||
</h2>
|
||||
{statsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : !normalRows.length && !packageRows.length ? (
|
||||
<p className="text-sm text-muted-foreground">No revenue data yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Regular */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Regular</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{normalRows.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: normalRows.map(renderCurrencyRow)}
|
||||
{normalRows.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 tabular-nums">{formatCurrency(normalGrand, 'ETB')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Package */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Package</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{packageRows.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: packageRows.map(renderCurrencyRow)}
|
||||
{packageRows.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 tabular-nums">{formatCurrency(packageGrand, 'ETB')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 mt-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground">Grand Total (ETB equivalent)</span>
|
||||
<span className="text-lg font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
||||
{formatCurrency(overallGrand, 'ETB')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Trend */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">
|
||||
Revenue Trend{' '}
|
||||
<span className="text-xs font-normal text-muted-foreground">(confirmed, ETB)</span>
|
||||
</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(value: number) => [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#10b981" dot={{ r: 4 }} activeDot={{ r: 6 }} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Confirmed Bookings */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Daily Confirmed Bookings</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="bookings" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Status Distribution */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Booking Status Distribution</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: 'Confirmed', value: bookings.filter((b: any) => 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) => <Cell key={idx} fill={color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Payment Methods</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<div className="space-y-3 pt-1">
|
||||
{(Object.entries(
|
||||
bookings.reduce((acc: Record<string, number>, b: any) => {
|
||||
const method = b.paymentIntent?.method || 'Unknown';
|
||||
acc[method] = (acc[method] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
) 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 (
|
||||
<div key={method} className="flex items-center gap-3">
|
||||
<span className="text-sm w-32 shrink-0 capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums w-8 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Summary</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="border border-border rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold mt-1 tabular-nums">
|
||||
{fromStats && statsLoading ? '—' : value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Exports daily confirmed-booking revenue for the selected date range. Cancelled and refunded bookings are excluded.
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Format</p>
|
||||
<div className="flex gap-3">
|
||||
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
|
||||
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="reportExportFormat" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={doExport}>Export</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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, '<').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<any[]>({
|
||||
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<string, any>, 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 => `<th>${esc(h)}</th>`).join('');
|
||||
const tbody = rows.map(r => `<tr>${r.map(v => `<td>${esc(v)}</td>`).join('')}</tr>`).join('');
|
||||
w.document.write(
|
||||
`<!DOCTYPE html><html><head><title>Revenue Report</title>` +
|
||||
`<style>body{font-family:sans-serif;font-size:11px}table{border-collapse:collapse;width:100%}` +
|
||||
`th,td{border:1px solid #ccc;padding:4px 8px}th{background:#10b981;color:#fff}</style></head><body>` +
|
||||
`<h2>Revenue Report — ${esc(dates.startDate)} to ${esc(dates.endDate)}</h2>` +
|
||||
`<p>Total Revenue: ${esc(formatCurrency(overallGrand, 'ETB'))} | ` +
|
||||
`Bookings: ${esc(String(stats?.totalBookings ?? 0))} | ` +
|
||||
`Tickets: ${esc(String(stats?.totalTickets ?? 0))}</p>` +
|
||||
`<table><thead><tr>${thead}</tr></thead><tbody>${tbody}</tbody></table></body></html>`
|
||||
);
|
||||
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 (
|
||||
<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">{currency}</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums">
|
||||
{formatCurrency(totalMinor, currency)}
|
||||
{currency !== 'ETB' && etbMinor !== null && (
|
||||
<span className="ml-1.5 text-xs font-normal text-muted-foreground">
|
||||
({formatCurrency(etbMinor, 'ETB')})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Reports & Analytics</h1>
|
||||
<p className="text-muted-foreground mt-1">Revenue figures reflect confirmed tickets only</p>
|
||||
</div>
|
||||
|
||||
{/* Date Range Selector */}
|
||||
<div className="card">
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div>
|
||||
<label className="label">Date Range</label>
|
||||
<select className="input" value={dateRange} onChange={(e) => setDateRange(e.target.value)} disabled={isLoading}>
|
||||
<option value="7">Last 7 Days</option>
|
||||
<option value="30">Last 30 Days</option>
|
||||
<option value="90">Last 90 Days</option>
|
||||
<option value="custom">Custom Range</option>
|
||||
</select>
|
||||
</div>
|
||||
{dateRange === 'custom' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="label">Start Date</label>
|
||||
<input type="date" className="input" value={startDate} onChange={(e) => setStartDate(e.target.value)} disabled={isLoading} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">End Date</label>
|
||||
<input type="date" className="input" value={endDate} onChange={(e) => setEndDate(e.target.value)} disabled={isLoading} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<ActionButton icon={Download} variant="secondary" disabled={isLoading} onClick={() => setExportModalOpen(true)}>
|
||||
Export
|
||||
</ActionButton>
|
||||
</div>
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||
</div>
|
||||
|
||||
{/* Key Metrics */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{/* Total Revenue */}
|
||||
<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 Revenue</p>
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-emerald-600 dark:text-emerald-400 tabular-nums mt-1">
|
||||
{statsLoading ? '—' : formatCurrency(overallGrand, 'ETB')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(normalGrand, 'ETB')}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : formatCurrency(packageGrand, 'ETB')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Bookings */}
|
||||
<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 Bookings</p>
|
||||
<div className="rounded-lg bg-blue-100 dark:bg-blue-900/30 p-1.5">
|
||||
<BookOpen className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{statsLoading ? '—' : (stats?.totalBookings ?? 0).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalBookings ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageBookings ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Total Tickets */}
|
||||
<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 Tickets</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5">
|
||||
<Ticket className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{statsLoading ? '—' : (stats?.totalTickets ?? 0).toLocaleString()}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1 border-t border-border pt-2 mt-1">
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Regular</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalNormalTickets ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Package</span>
|
||||
<span className="font-semibold tabular-nums">{statsLoading ? '—' : (stats?.totalPackageTickets ?? 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Avg Daily Revenue */}
|
||||
<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">Avg. Daily Revenue</p>
|
||||
<div className="rounded-lg bg-purple-100 dark:bg-purple-900/30 p-1.5">
|
||||
<TrendingUp className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">
|
||||
{isLoading ? '—' : formatCurrency(avgDailyRevenue * 100, 'ETB')}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-auto pt-2 border-t border-border">
|
||||
Over {chartData.length} active day{chartData.length !== 1 ? 's' : ''} in range
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Breakdown by Currency */}
|
||||
<div className="card">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground mb-4">
|
||||
Revenue Breakdown — Confirmed Tickets
|
||||
</h2>
|
||||
{statsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : !normalRows.length && !packageRows.length ? (
|
||||
<p className="text-sm text-muted-foreground">No revenue data yet.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Regular */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Regular</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalNormalBookings ?? 0).toLocaleString()} bookings · {(stats?.totalNormalTickets ?? 0).toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{normalRows.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: normalRows.map(renderCurrencyRow)}
|
||||
{normalRows.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 tabular-nums">{formatCurrency(normalGrand, 'ETB')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Package */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Package</span>
|
||||
<span className="text-xs text-muted-foreground tabular-nums">
|
||||
{(stats?.totalPackageBookings ?? 0).toLocaleString()} bookings · {(stats?.totalPackageTickets ?? 0).toLocaleString()} tickets
|
||||
</span>
|
||||
</div>
|
||||
{packageRows.length === 0
|
||||
? <p className="text-xs text-muted-foreground py-1">No revenue yet</p>
|
||||
: packageRows.map(renderCurrencyRow)}
|
||||
{packageRows.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 tabular-nums">{formatCurrency(packageGrand, 'ETB')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!statsLoading && (normalRows.length > 0 || packageRows.length > 0) && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-muted/30 px-4 py-3 mt-4">
|
||||
<span className="text-sm font-semibold text-muted-foreground">Grand Total (ETB equivalent)</span>
|
||||
<span className="text-lg font-bold text-emerald-600 dark:text-emerald-400 tabular-nums">
|
||||
{formatCurrency(overallGrand, 'ETB')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Revenue Trend */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">
|
||||
Revenue Trend{' '}
|
||||
<span className="text-xs font-normal text-muted-foreground">(confirmed, ETB)</span>
|
||||
</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip formatter={(value: number) => [`ETB ${Math.round(value).toLocaleString()}`, 'Revenue']} />
|
||||
<Legend />
|
||||
<Line type="monotone" dataKey="revenue" stroke="#10b981" dot={{ r: 4 }} activeDot={{ r: 6 }} strokeWidth={2} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Daily Confirmed Bookings */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Daily Confirmed Bookings</h3>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
|
||||
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} />
|
||||
<Tooltip />
|
||||
<Bar dataKey="bookings" fill="#3b82f6" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booking Status Distribution */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Booking Status Distribution</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={[
|
||||
{ name: 'Confirmed', value: bookings.filter((b: any) => 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) => <Cell key={idx} fill={color} />)}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Payment Methods</h3>
|
||||
{bookings.length > 0 ? (
|
||||
<div className="space-y-3 pt-1">
|
||||
{(Object.entries(
|
||||
bookings.reduce((acc: Record<string, number>, b: any) => {
|
||||
const method = b.paymentIntent?.method || 'Unknown';
|
||||
acc[method] = (acc[method] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>)
|
||||
) 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 (
|
||||
<div key={method} className="flex items-center gap-3">
|
||||
<span className="text-sm w-32 shrink-0 capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-2">
|
||||
<div className="bg-primary h-2 rounded-full" style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="text-sm font-semibold tabular-nums w-8 text-right">{count}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
|
||||
No data for selected range
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="card">
|
||||
<h3 className="text-base font-semibold mb-4">Summary</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
|
||||
{[
|
||||
{ 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 }) => (
|
||||
<div key={label} className="border border-border rounded-lg p-3 text-center">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold mt-1 tabular-nums">
|
||||
{fromStats && statsLoading ? '—' : value.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export Modal */}
|
||||
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Revenue Report" size="sm">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Exports daily confirmed-booking revenue for the selected date range. Cancelled and refunded bookings are excluded.
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Format</p>
|
||||
<div className="flex gap-3">
|
||||
{(['csv', 'excel', 'pdf'] as const).map(fmt => (
|
||||
<label key={fmt} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="reportExportFormat" value={fmt} checked={exportFormat === fmt} onChange={() => setExportFormat(fmt)} className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<ActionButton variant="secondary" onClick={() => setExportModalOpen(false)}>Cancel</ActionButton>
|
||||
<ActionButton onClick={doExport}>Export</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
redirect('/reports/overall');
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
|
||||
<th className="pb-2 pr-4">Name</th>
|
||||
<th className="pb-2 pr-4">Nationality</th>
|
||||
<th className="pb-2 pr-4">Coach · Seat</th>
|
||||
<th className="pb-2 pr-4">Trip</th>
|
||||
<th className="pb-2 pr-4">Passport</th>
|
||||
<th className="pb-2 pr-4">Coach</th>
|
||||
<th className="pb-2 pr-4">Seat</th>
|
||||
<th className="pb-2 pr-4">Class</th>
|
||||
<th className="pb-2 pr-4">Origin</th>
|
||||
<th className="pb-2 pr-4">Destination</th>
|
||||
<th className="pb-2 pr-4">Amount Paid</th>
|
||||
<th className="pb-2">Booking Ref</th>
|
||||
</tr>
|
||||
@@ -329,15 +339,13 @@ export default function PassengersReportPage() {
|
||||
{filteredList.map((p, i) => (
|
||||
<tr key={`${p.bookingRef}-${i}`} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-medium">{p.passengerName}</td>
|
||||
<td className="py-2 pr-4 text-xs whitespace-nowrap">
|
||||
{[p.nationality, p.passportNumber].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs whitespace-nowrap">
|
||||
{[p.coachNumber, p.seatNumber ?? p.seatLabel, p.seatClassName].filter(Boolean).join(' · ') || '—'}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-muted-foreground text-xs">
|
||||
{p.origin && p.destination ? `${p.origin} → ${p.destination}` : (p.origin ?? p.destination ?? '—')}
|
||||
</td>
|
||||
<td className="py-2 pr-4 text-xs">{p.nationality ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{p.passportNumber ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{p.coachNumber ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{p.seatNumber ?? p.seatLabel ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">{p.seatClassName ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">{p.origin ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">{p.destination ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="tabular-nums font-medium">
|
||||
@@ -359,7 +367,7 @@ export default function PassengersReportPage() {
|
||||
</tr>
|
||||
))}
|
||||
{filteredList.length === 0 && (
|
||||
<tr><td colSpan={6} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
|
||||
<tr><td colSpan={10} className="py-8 text-center text-sm text-muted-foreground">No passengers found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -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<ScheduleOption[]>({
|
||||
queryKey: ['report-schedules'],
|
||||
queryFn: () => apiClient.get('/reports/schedules'),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
const { data, isLoading } = useQuery<SeatStatusReport>({
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<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
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">Booked and manually blocked seats for a schedule</p>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<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-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>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-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>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{blockedSeats.length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||
</div>
|
||||
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{/* Schedule selector */}
|
||||
<div className="card">
|
||||
<div className="flex flex-wrap items-end gap-4">
|
||||
<div className="flex-1 min-w-48">
|
||||
<label className="label">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input"
|
||||
placeholder="Booking ref, passenger, seat, coach..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Payment Status</label>
|
||||
<div className="flex items-end gap-4 flex-wrap">
|
||||
<div className="flex-1 min-w-72">
|
||||
<label className="label">Schedule</label>
|
||||
<select
|
||||
className="input"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value as 'ALL' | 'PAID' | 'UNPAID')}
|
||||
value={scheduleId}
|
||||
onChange={e => { setScheduleId(e.target.value); setSearch(''); setPayFilter('ALL'); }}
|
||||
disabled={loadingSchedules}
|
||||
>
|
||||
<option value="ALL">All Seats</option>
|
||||
<option value="PAID">Paid Only</option>
|
||||
<option value="UNPAID">Unpaid Only</option>
|
||||
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
|
||||
{schedules.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<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 */}
|
||||
<div className="card p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{[
|
||||
'Booking Ref',
|
||||
'Passenger',
|
||||
'Seat / Coach',
|
||||
'Fare',
|
||||
'Payment',
|
||||
'Booked At',
|
||||
'Release At',
|
||||
'Route',
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<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';
|
||||
const expired = isExpired(row.releaseAt);
|
||||
return (
|
||||
<tr
|
||||
key={i}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<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">
|
||||
<span className="font-semibold">{row.seatNumber}</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>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap text-muted-foreground">
|
||||
{row.bookedAt ? formatDateTime(row.bookedAt) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm whitespace-nowrap">
|
||||
{isPaid ? (
|
||||
<span className="text-green-600 dark:text-green-400 text-xs font-medium">
|
||||
— Paid
|
||||
</span>
|
||||
) : row.releaseAt ? (
|
||||
<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'
|
||||
}
|
||||
>
|
||||
{expired ? '⚠ ' : '⏱ '}
|
||||
{formatDateTime(row.releaseAt)}
|
||||
{expired && ' (expired)'}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</td>
|
||||
<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>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!isLoading && filtered.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
<Armchair className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>No seats found</p>
|
||||
{data && (
|
||||
<>
|
||||
{/* Summary cards */}
|
||||
<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">Booked Seats</p>
|
||||
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-1.5"><Armchair className="h-4 w-4 text-emerald-600 dark:text-emerald-400" /></div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{bookedSeats.length}</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">Paid</p>
|
||||
<div className="rounded-lg bg-green-100 dark:bg-green-900/30 p-1.5"><CheckCircle className="h-4 w-4 text-green-600 dark:text-green-400" /></div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{paidCount}</p>
|
||||
<p className="text-xs text-muted-foreground">{bookedSeats.length - paidCount} unpaid</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">Blocked Seats</p>
|
||||
<div className="rounded-lg bg-slate-100 dark:bg-slate-900/30 p-1.5"><Ban className="h-4 w-4 text-slate-600 dark:text-slate-400" /></div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tabular-nums mt-1">{blockedSeats.length}</p>
|
||||
<p className="text-xs text-muted-foreground">Manually blocked</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Booked seats */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground flex-1">Booked Seats</h2>
|
||||
<input
|
||||
type="text"
|
||||
className="input max-w-xs"
|
||||
placeholder="Search name, ref, seat…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
<select className="input w-36" value={payFilter} onChange={e => setPayFilter(e.target.value)}>
|
||||
<option value="ALL">All</option>
|
||||
<option value="PAID">Paid only</option>
|
||||
<option value="UNPAID">Unpaid only</option>
|
||||
</select>
|
||||
{filteredBooked.length > 0 && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExportBooked}>Export CSV</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<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">Booking Ref</th>
|
||||
<th className="pb-2 pr-4">Passenger</th>
|
||||
<th className="pb-2 pr-4">Coach</th>
|
||||
<th className="pb-2 pr-4">Seat</th>
|
||||
<th className="pb-2 pr-4">Class</th>
|
||||
<th className="pb-2 pr-4">Fare</th>
|
||||
<th className="pb-2 pr-4">Payment</th>
|
||||
<th className="pb-2 pr-4">Booking Status</th>
|
||||
<th className="pb-2">Booked At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filteredBooked.map((r, i) => {
|
||||
const isPaid = r.paymentStatus === 'SUCCEEDED' || r.paymentStatus === 'COMPLETED';
|
||||
return (
|
||||
<tr key={i} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-mono text-xs font-semibold">{r.bookingRef}</td>
|
||||
<td className="py-2 pr-4 font-medium">{r.passengerName}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{r.coachNumber ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{r.seatNumber ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">{r.seatClassName ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs tabular-nums">{formatCurrency(r.fareMinor, r.currency)}</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant="status" status={isPaid ? 'PAID' : r.paymentStatus}>
|
||||
{isPaid ? 'PAID' : r.paymentStatus}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge variant="status" status={r.bookingStatus}>{r.bookingStatus}</Badge>
|
||||
</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">{r.bookedAt ? formatDateTime(r.bookedAt) : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{filteredBooked.length === 0 && (
|
||||
<tr><td colSpan={9} className="py-8 text-center text-sm text-muted-foreground">No booked seats found</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Blocked seats */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground flex-1">Manually Blocked Seats</h2>
|
||||
{blockedSeats.length > 0 && (
|
||||
<ActionButton icon={Download} variant="secondary" onClick={doExportBlocked}>Export CSV</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<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">Coach</th>
|
||||
<th className="pb-2 pr-4">Seat</th>
|
||||
<th className="pb-2 pr-4">Class</th>
|
||||
<th className="pb-2 pr-4">Reason</th>
|
||||
<th className="pb-2 pr-4">Blocked By</th>
|
||||
<th className="pb-2 pr-4">Blocked At</th>
|
||||
<th className="pb-2">Unblock At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{blockedSeats.map((r, i) => (
|
||||
<tr key={i} className="hover:bg-muted/30">
|
||||
<td className="py-2 pr-4 font-mono text-xs">{r.coachNumber ?? '—'}</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{r.seatNumber ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">{r.seatClassName ?? '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">{r.reason}</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">{r.blockedBy}</td>
|
||||
<td className="py-2 pr-4 text-xs text-muted-foreground">{formatDateTime(r.blockedAt)}</td>
|
||||
<td className="py-2 text-xs text-muted-foreground">{r.unblockAt ? formatDateTime(r.unblockAt) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
{blockedSeats.length === 0 && (
|
||||
<tr><td colSpan={7} className="py-8 text-center text-sm text-muted-foreground">No manually blocked seats</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!scheduleId && !isLoading && (
|
||||
<div className="card py-16 text-center text-muted-foreground">
|
||||
<Clock className="h-10 w-10 mx-auto mb-3 opacity-30" />
|
||||
<p>Select a schedule to view seat status</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user