Merge pull request #825 from Tria-plc/quick-fix-main

Reports (overall, seats report, passenger export) updates
This commit is contained in:
Stephanos A.
2026-07-20 00:42:46 +03:00
committed by GitHub
6 changed files with 659 additions and 541 deletions

View File

@@ -496,6 +496,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 },

View File

@@ -0,0 +1,5 @@
import DashboardLayout from '../../dashboard/layout';
export default function OverallReportLayout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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>
);
}

View File

@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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');
}

View File

@@ -499,8 +499,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>

View File

@@ -15,23 +15,32 @@ 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;
interface BlockedSeatRow {
id: string;
coachNumber: string | null;
seatNumber: string | null;
seatClassName: string | null;
reason: string;
blockedBy: string;
blockedAt: string;
unblockAt: string | null;
}
function getReleaseAt(booking: any, seat: any): string | null {
const paymentStatus = booking.paymentIntent?.status || "PENDING";
@@ -47,9 +56,14 @@ function getReleaseAt(booking: any, seat: any): string | null {
return null;
}
function isExpired(releaseAt: string | null): boolean {
if (!releaseAt) return false;
return new Date(releaseAt) < new Date();
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() {
@@ -65,15 +79,15 @@ export default function SeatStatusReportPage() {
.getBlocked()
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
});
const schedules = schedulesRaw ?? [];
const { data: bookingsData, isLoading } = useQuery({
queryKey: ["seat-report-bookings"],
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
});
const rows: SeatRow[] = useMemo(() => {
const bookings: any[] = bookingsData?.items || [];
const result: SeatRow[] = [];
const bookedSeats = data?.bookedSeats ?? [];
const blockedSeats = data?.blockedSeats ?? [];
for (const booking of bookings) {
if (booking.status === "CANCELLED") continue;
@@ -103,6 +117,8 @@ export default function SeatStatusReportPage() {
});
}
}
return true;
});
return result;
}, [bookingsData]);
@@ -208,6 +224,8 @@ export default function SeatStatusReportPage() {
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
</div>
</div>
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
</div>
<div className="card">
<div className="flex items-start justify-between">
@@ -239,9 +257,7 @@ export default function SeatStatusReportPage() {
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">
@@ -256,7 +272,6 @@ export default function SeatStatusReportPage() {
Manually blocked
</p>
</div>
<Ban className="h-8 w-8 text-slate-500 opacity-30" />
</div>
{blockedSeats.length > 0 && (
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
@@ -422,13 +437,7 @@ export default function SeatStatusReportPage() {
</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>
</div>
)}
</div>
)}
</div>
);
}