Seats report, supplementary change for bookings added

This commit is contained in:
Stephanos A
2026-07-16 11:43:04 +03:00
parent 3bc492ea83
commit a852b4e619
15 changed files with 1392 additions and 176 deletions

View File

@@ -0,0 +1,290 @@
'use client';
import { useState } from 'react';
import { Send, CheckCircle, XCircle, RotateCcw, PlusCircle } from 'lucide-react';
import Modal from '@/components/ui/Modal';
import ActionButton from '@/components/ui/ActionButton';
import Badge from '@/components/ui/Badge';
import { formatCurrency, formatDateTime } from '@/lib/utils';
import {
useSupplementaryCharges,
useCreateSupplementaryCharge,
useMarkSupplementaryPaid,
useWaiveSupplementaryCharge,
useResendSupplementaryLink,
} from './useSupplementaryCharges';
type Tab = 'create' | 'list';
interface Props {
isOpen: boolean;
onClose: () => void;
}
const REASONS = ['UNDERPAYMENT', 'FARE_CORRECTION', 'CURRENCY_ADJUSTMENT', 'OTHER'];
const STATUS_COLORS: Record<string, string> = {
PENDING: 'warning',
PAID: 'success',
WAIVED: 'info',
EXPIRED: 'error',
};
export default function SupplementaryChargesModal({ isOpen, onClose }: Props) {
const [tab, setTab] = useState<Tab>('create');
const [listFilters, setListFilters] = useState({ bookingRef: '', status: '' });
// Create form state
const [form, setForm] = useState({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
const [formError, setFormError] = useState<string | null>(null);
const [createSuccess, setCreateSuccess] = useState<string | null>(null);
const { data: chargesData, isLoading } = useSupplementaryCharges(listFilters);
const charges: any[] = (chargesData as any)?.items ?? (Array.isArray(chargesData) ? chargesData : []);
const createMutation = useCreateSupplementaryCharge(() => {
setCreateSuccess(`Charge created and payment link sent.`);
setForm({ bookingRef: '', amountEtb: '', reason: 'UNDERPAYMENT', notes: '' });
setFormError(null);
setTimeout(() => { setCreateSuccess(null); setTab('list'); }, 2000);
});
const markPaidMutation = useMarkSupplementaryPaid();
const waiveMutation = useWaiveSupplementaryCharge();
const resendMutation = useResendSupplementaryLink();
const [actionError, setActionError] = useState<string | null>(null);
const [actionSuccess, setActionSuccess] = useState<string | null>(null);
const flash = (msg: string) => {
setActionSuccess(msg);
setTimeout(() => setActionSuccess(null), 3000);
};
const handleCreate = async () => {
setFormError(null);
const amountMinor = Math.round(parseFloat(form.amountEtb) * 100);
if (!form.bookingRef.trim()) return setFormError('Booking reference is required');
if (!form.amountEtb || isNaN(amountMinor) || amountMinor <= 0) return setFormError('Enter a valid amount');
try {
await createMutation.mutateAsync({ bookingRef: form.bookingRef.trim(), amountMinor, reason: form.reason, notes: form.notes || undefined });
} catch (e: any) {
setFormError(e?.response?.data?.message ?? e?.message ?? 'Failed to create charge');
}
};
const handleMarkPaid = async (id: string) => {
setActionError(null);
try {
await markPaidMutation.mutateAsync({ id });
flash('Marked as paid');
} catch (e: any) {
setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed');
}
};
const handleWaive = async (id: string) => {
setActionError(null);
try {
await waiveMutation.mutateAsync({ id });
flash('Charge waived');
} catch (e: any) {
setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed');
}
};
const handleResend = async (id: string) => {
setActionError(null);
try {
await resendMutation.mutateAsync(id);
flash('Payment link resent');
} catch (e: any) {
setActionError(e?.response?.data?.message ?? e?.message ?? 'Failed');
}
};
return (
<Modal isOpen={isOpen} onClose={onClose} title="Supplementary Charges" size="xl">
{/* Tabs */}
<div className="flex gap-1 mb-5 border-b border-muted">
{(['create', 'list'] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
tab === t
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{t === 'create' ? '+ Raise Charge' : 'All Charges'}
</button>
))}
</div>
{/* ── CREATE TAB ── */}
{tab === 'create' && (
<div className="space-y-4">
{createSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200"> {createSuccess}</div>
)}
{formError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{formError}</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="label">Booking Reference <span className="text-red-500">*</span></label>
<input
className="input"
placeholder="e.g. EDR-20240001"
value={form.bookingRef}
onChange={(e) => setForm({ ...form, bookingRef: e.target.value })}
/>
</div>
<div>
<label className="label">Amount Owed (ETB) <span className="text-red-500">*</span></label>
<input
className="input"
type="number"
min="0.01"
step="0.01"
placeholder="e.g. 50.00"
value={form.amountEtb}
onChange={(e) => setForm({ ...form, amountEtb: e.target.value })}
/>
</div>
<div>
<label className="label">Reason <span className="text-red-500">*</span></label>
<select className="input" value={form.reason} onChange={(e) => setForm({ ...form, reason: e.target.value })}>
{REASONS.map((r) => <option key={r} value={r}>{r.replace('_', ' ')}</option>)}
</select>
</div>
<div className="md:col-span-2">
<label className="label">Notes (optional)</label>
<textarea
className="input resize-none"
rows={2}
placeholder="e.g. Passenger paid 350 ETB, correct fare is 400 ETB"
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
/>
</div>
</div>
<p className="text-xs text-muted-foreground">
A payment link will be sent to the passenger's registered phone/email. The link expires in 72 hours.
</p>
<div className="flex justify-end gap-2 pt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={() => setTab('list')}>Cancel</ActionButton>
<ActionButton icon={PlusCircle} onClick={handleCreate} loading={createMutation.isPending}>
Raise Charge
</ActionButton>
</div>
</div>
)}
{/* ── LIST TAB ── */}
{tab === 'list' && (
<div className="space-y-4">
{actionSuccess && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-3 text-sm text-green-800 dark:text-green-200">✓ {actionSuccess}</div>
)}
{actionError && (
<div className="rounded-lg bg-red-50 dark:bg-red-900/20 p-3 text-sm text-red-700 dark:text-red-300">{actionError}</div>
)}
{/* Filters */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="label">Booking Ref</label>
<input
className="input"
placeholder="Search booking ref…"
value={listFilters.bookingRef}
onChange={(e) => setListFilters({ ...listFilters, bookingRef: e.target.value })}
/>
</div>
<div>
<label className="label">Status</label>
<select className="input" value={listFilters.status} onChange={(e) => setListFilters({ ...listFilters, status: e.target.value })}>
<option value="">All</option>
<option value="PENDING">Pending</option>
<option value="PAID">Paid</option>
<option value="WAIVED">Waived</option>
<option value="EXPIRED">Expired</option>
</select>
</div>
</div>
{/* Table */}
{isLoading ? (
<p className="text-sm text-muted-foreground py-6 text-center">Loading…</p>
) : charges.length === 0 ? (
<p className="text-sm text-muted-foreground py-6 text-center">No supplementary charges found.</p>
) : (
<div className="overflow-x-auto rounded-lg border border-muted">
<table className="w-full text-sm">
<thead>
<tr className="bg-muted/40 text-left text-xs font-semibold uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2">Booking</th>
<th className="px-3 py-2">Amount</th>
<th className="px-3 py-2">Reason</th>
<th className="px-3 py-2">Status</th>
<th className="px-3 py-2">Created</th>
<th className="px-3 py-2">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-muted">
{charges.map((c: any) => (
<tr key={c.id} className="hover:bg-muted/20 transition-colors">
<td className="px-3 py-2 font-mono text-xs">{c.booking?.bookingRef ?? c.bookingId.substring(0, 8)}</td>
<td className="px-3 py-2 font-semibold">{formatCurrency(c.amountMinor, c.currency ?? 'ETB')}</td>
<td className="px-3 py-2 text-xs">{c.reason}</td>
<td className="px-3 py-2">
<Badge variant="status" status={STATUS_COLORS[c.status] ?? c.status}>{c.status}</Badge>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{formatDateTime(c.createdAt)}</td>
<td className="px-3 py-2">
{c.status === 'PENDING' && (
<div className="flex gap-1">
<button
title="Mark paid"
onClick={() => handleMarkPaid(c.id)}
className="p-1 rounded hover:bg-green-100 dark:hover:bg-green-900/30 text-green-600"
>
<CheckCircle size={15} />
</button>
<button
title="Waive"
onClick={() => handleWaive(c.id)}
className="p-1 rounded hover:bg-red-100 dark:hover:bg-red-900/30 text-red-500"
>
<XCircle size={15} />
</button>
<button
title="Resend link"
onClick={() => handleResend(c.id)}
className="p-1 rounded hover:bg-blue-100 dark:hover:bg-blue-900/30 text-blue-500"
>
<RotateCcw size={15} />
</button>
</div>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="flex justify-end pt-2 border-t border-muted">
<ActionButton variant="secondary" onClick={onClose}>Close</ActionButton>
</div>
</div>
)}
</Modal>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Eye, Trash2 } from 'lucide-react';
import { Download, Eye, Trash2, AlertCircle } from 'lucide-react';
import DataTable from '@/components/ui/DataTable';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
@@ -10,6 +10,7 @@ import Modal from '@/components/ui/Modal';
import ConfirmDialog from '@/components/ui/ConfirmDialog';
import { paymentsApi, apiClient } from '@/lib/api';
import { formatDateTime, formatCurrency } from '@/lib/utils';
import SupplementaryChargesModal from './SupplementaryChargesModal';
const Field = ({ label, value, mono = false, truncate = false }: { label: string; value: string; mono?: boolean; truncate?: boolean }) => (
<div className="bg-muted/40 rounded-lg p-3">
@@ -37,6 +38,7 @@ export default function PaymentsPage() {
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
reference: true, booking: true, amount: true, method: true, status: true, createdAt: true,
});
const [supplementaryOpen, setSupplementaryOpen] = useState(false);
const queryClient = useQueryClient();
@@ -134,7 +136,10 @@ export default function PaymentsPage() {
<h1 className="text-2xl font-bold text-foreground">Payments</h1>
<p className="text-muted-foreground">Manage payment transactions and refunds</p>
</div>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
<div className="flex items-center gap-2">
<ActionButton icon={AlertCircle} variant="secondary" onClick={() => setSupplementaryOpen(true)}>Supplementary Charges</ActionButton>
<ActionButton icon={Download} variant="export" onClick={() => setExportModalOpen(true)}>Export</ActionButton>
</div>
</div>
<div className="card">
@@ -280,6 +285,8 @@ export default function PaymentsPage() {
error={deleteError ?? undefined}
/>
<SupplementaryChargesModal isOpen={supplementaryOpen} onClose={() => setSupplementaryOpen(false)} />
{/* Export Modal */}
<Modal isOpen={exportModalOpen} onClose={() => setExportModalOpen(false)} title="Export Payments" size="md">
<div className="space-y-4">

View File

@@ -0,0 +1,47 @@
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentsApi } from '@/lib/api';
export function useSupplementaryCharges(filters: { bookingRef?: string; status?: string }) {
return useQuery({
queryKey: ['supplementary-charges', filters],
queryFn: () => paymentsApi.supplementary.getAll(filters),
});
}
export function useCreateSupplementaryCharge(onSuccess: () => void) {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
paymentsApi.supplementary.create(data),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['supplementary-charges'] });
onSuccess();
},
});
}
export function useMarkSupplementaryPaid() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, providerTxnId }: { id: string; providerTxnId?: string }) =>
paymentsApi.supplementary.markPaid(id, providerTxnId),
onSuccess: () => qc.invalidateQueries({ queryKey: ['supplementary-charges'] }),
});
}
export function useWaiveSupplementaryCharge() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, notes }: { id: string; notes?: string }) =>
paymentsApi.supplementary.waive(id, notes),
onSuccess: () => qc.invalidateQueries({ queryKey: ['supplementary-charges'] }),
});
}
export function useResendSupplementaryLink() {
return useMutation({
mutationFn: (id: string) => paymentsApi.supplementary.resend(id),
});
}

View File

@@ -2,13 +2,23 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, TrendingUp, Users, DollarSign, AlertCircle } from 'lucide-react';
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts';
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 COLORS = ['#3b82f6', '#10b981', '#f59e0b'];
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');
@@ -21,23 +31,13 @@ export default function ReportsPage() {
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;
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 };
}
if (startDate && endDate) return { startDate, endDate };
}
return {
startDate: start.toISOString().split('T')[0],
endDate: end.toISOString().split('T')[0],
@@ -46,37 +46,61 @@ export default function ReportsPage() {
const dates = getDateRange();
// Fetch all bookings
const { data: bookingsData, isLoading } = useQuery({
// 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 }),
});
// Filter bookings by date range — exclude CANCELLED from revenue calculations
const bookings = Array.isArray(bookingsData?.items)
? bookingsData.items.filter((b: any) => {
const bookingDate = new Date(b.createdAt).toISOString().split('T')[0];
return bookingDate >= dates.startDate && bookingDate <= dates.endDate;
})
: [];
const isLoading = statsLoading || bookingsLoading;
const revenueBookings = bookings.filter((b: any) => b.status !== 'CANCELLED' && b.status !== 'REFUNDED');
const allBookings: any[] = Array.isArray(bookingsData?.items) ? bookingsData.items : [];
// Calculate metrics — revenue excludes cancelled/refunded bookings
const totalRevenue = revenueBookings.reduce((sum: number, b: any) => sum + (b.totalMinor || 0), 0);
const totalBookings = bookings.length;
const avgTicketPrice = revenueBookings.length > 0 ? Math.round(totalRevenue / revenueBookings.length) : 0;
const bookings = allBookings.filter((b: any) => {
const d = new Date(b.createdAt).toISOString().split('T')[0];
return d >= dates.startDate && d <= dates.endDate;
});
// Group by date for revenue chart — exclude cancelled/refunded
const byDate = revenueBookings.reduce((acc: Record<string, any>, b: any) => {
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 };
}
if (!acc[date]) acc[date] = { totalMinor: 0, count: 0 };
acc[date].totalMinor += b.totalMinor || 0;
acc[date].count += 1;
return acc;
}, {} as Record<string, any>);
}, {});
const chartData = Object.entries(byDate)
.sort(([a], [b]) => a.localeCompare(b))
@@ -86,33 +110,37 @@ export default function ReportsPage() {
bookings: d.count || 0,
}));
const REPORT_COLS = [
{ key: 'date', label: 'Date' },
{ key: 'revenue', label: 'Revenue (ETB)' },
{ key: 'bookings', label: 'Bookings' },
];
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 headers = REPORT_COLS.map(c => c.label);
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')!;
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>`);
w.document.write(`<h2>Revenue Report — ${dates.startDate} to ${dates.endDate}</h2>`);
w.document.write(`<p>Total Revenue: ETB ${Math.round(totalRevenue / 100).toLocaleString()} | Total Bookings: ${totalBookings} | Cancelled: ${bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>`);
w.document.write(`<table><thead><tr>${headers.map(h => `<th>${h}</th>`).join('')}</tr></thead><tbody>`);
rows.forEach(r => { w.document.write(`<tr>${r.map(v => `<td>${v}</td>`).join('')}</tr>`); });
w.document.write('</tbody></table></body></html>');
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 = [headers.join('\t'), ...rows.map(r => r.join('\t'))].join('\n');
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 = [headers.map(h => `"${h}"`).join(','), ...rows.map(r => r.map(v => `"${v}"`).join(','))].join('\n');
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);
@@ -120,11 +148,32 @@ export default function ReportsPage() {
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">View detailed reports and performance metrics</p>
<p className="text-muted-foreground mt-1">Revenue figures reflect confirmed tickets only</p>
</div>
{/* Date Range Selector */}
@@ -132,239 +181,327 @@ export default function ReportsPage() {
<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}
>
<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}
/>
<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}
/>
<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>
)}
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading</p>}
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md: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">Total Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {Math.round(totalRevenue / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Excl. cancelled &amp; refunded</p>
<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>
<DollarSign className="h-8 w-8 text-blue-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Total Bookings</p>
<p className="text-2xl font-bold mt-2">{totalBookings.toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">All bookings</p>
{/* 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>
<Users className="h-8 w-8 text-green-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Ticket Price</p>
<p className="text-2xl font-bold mt-2">ETB {(avgTicketPrice / 100).toLocaleString()}</p>
<p className="text-xs text-muted-foreground mt-1">Non-cancelled bookings</p>
{/* 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>
<TrendingUp className="h-8 w-8 text-purple-500 opacity-20" />
</div>
</div>
<div className="card">
<div className="flex items-start justify-between">
<div>
<p className="text-muted-foreground text-sm font-medium">Avg. Daily Revenue</p>
<p className="text-2xl font-bold mt-2">ETB {chartData.length > 0 ? Math.round((totalRevenue / 100) / chartData.length).toLocaleString() : '0'}</p>
<p className="text-xs text-muted-foreground mt-1">Daily average</p>
{/* 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>
<AlertCircle className="h-8 w-8 text-orange-500 opacity-20" />
</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-lg font-semibold mb-4">Revenue Trend</h3>
<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={300}>
<ResponsiveContainer width="100%" height={280}>
<LineChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip formatter={(value: number) => `ETB ${Math.round(value).toLocaleString()}`} />
<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="#3b82f6" dot={{ r: 5 }} activeDot={{ r: 7 }} strokeWidth={2} />
<Line type="monotone" dataKey="revenue" stroke="#10b981" dot={{ r: 4 }} activeDot={{ r: 6 }} strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Daily Bookings */}
{/* Daily Confirmed Bookings */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Daily Bookings</h3>
<h3 className="text-base font-semibold mb-4">Daily Confirmed Bookings</h3>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e5e7eb" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<XAxis dataKey="date" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
<Bar dataKey="bookings" fill="#10b981" />
<Bar dataKey="bookings" fill="#3b82f6" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<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-lg font-semibold mb-4">Booking Status</h3>
<h3 className="text-base font-semibold mb-4">Booking Status Distribution</h3>
{bookings.length > 0 ? (
<ResponsiveContainer width="100%" height={300}>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={[
{ name: 'Confirmed', value: bookings.filter((b: any) => b.status === 'CONFIRMED').length },
{ name: 'Completed', value: bookings.filter((b: any) => b.status === 'BOARDED').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%"
cx="50%" cy="50%"
labelLine={false}
label={({ name, value }) => `${name}: ${value}`}
outerRadius={100}
dataKey="value"
>
{COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
{STATUS_COLORS.map((color, idx) => <Cell key={idx} fill={color} />)}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
) : (
<div className="h-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
{/* Top Payment Methods */}
{/* Payment Methods */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Payment Methods</h3>
<h3 className="text-base font-semibold mb-4">Payment Methods</h3>
{bookings.length > 0 ? (
<div className="space-y-3">
<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][]
)
) as [string, number][])
.sort(([, a], [, b]) => b - a)
.slice(0, 5)
.map(([method, count]) => (
<div key={method} className="flex justify-between items-center p-2 bg-gray-50 dark:bg-gray-900 rounded">
<span className="text-sm capitalize">{method.toLowerCase().replace(/_/g, ' ')}</span>
<span className="font-semibold">{count}</span>
</div>
))}
.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-[300px] flex items-center justify-center text-muted-foreground">
No data available
<div className="h-[280px] flex items-center justify-center text-muted-foreground text-sm">
No data for selected range
</div>
)}
</div>
</div>
{/* Summary Stats */}
{/* Summary */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Summary</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Total Days with Bookings</p>
<p className="text-xl font-bold mt-2">{chartData.length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Confirmed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CONFIRMED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Completed Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'BOARDED').length}</p>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p className="text-sm text-muted-foreground">Cancelled Bookings</p>
<p className="text-xl font-bold mt-2">{bookings.filter((b: any) => b.status === 'CANCELLED').length}</p>
</div>
<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 revenue and booking counts for the selected date range. Cancelled and refunded bookings are excluded from revenue figures.</p>
<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">Export Format</p>
<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 capitalize">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
<span className="text-sm font-medium">{fmt === 'excel' ? 'Excel (.xls)' : fmt === 'pdf' ? 'PDF (Print)' : 'CSV'}</span>
</label>
))}
</div>

View File

@@ -0,0 +1,3 @@
export default function Layout({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}

View File

@@ -0,0 +1,324 @@
'use client';
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Download, Armchair, CheckCircle, Clock, AlertCircle } from 'lucide-react';
import { bookingsApi } from '@/lib/api';
import Badge from '@/components/ui/Badge';
import ActionButton from '@/components/ui/ActionButton';
import { formatDateTime, formatCurrency } from '@/lib/utils';
interface SeatRow {
bookingRef: string;
passengerName: string;
seatNumber: string;
coachNumber: string;
fareMinor: number;
currency: string;
paymentStatus: string;
bookingStatus: 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;
}
function isExpired(releaseAt: string | null): boolean {
if (!releaseAt) return false;
return new Date(releaseAt) < new Date();
}
export default function SeatStatusReportPage() {
const [statusFilter, setStatusFilter] = useState<'ALL' | 'PAID' | 'UNPAID'>('ALL');
const [search, setSearch] = useState('');
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[] = [];
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 || '',
});
}
}
return result;
}, [bookingsData]);
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 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);
};
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>
</div>
{/* Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 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>
{/* Filters */}
<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>
<select
className="input"
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as 'ALL' | 'PAID' | 'UNPAID')}
>
<option value="ALL">All Seats</option>
<option value="PAID">Paid Only</option>
<option value="UNPAID">Unpaid Only</option>
</select>
</div>
<ActionButton icon={Download} variant="secondary" onClick={doExport} disabled={isLoading}>
Export CSV
</ActionButton>
</div>
{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>
</div>
)}
</div>
</div>
);
}

View File

@@ -119,7 +119,8 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{
title: 'Analytics & Reports',
items: [
{ name: 'Reports', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
]
},

View File

@@ -184,6 +184,25 @@ export const paymentsApi = {
addMethod: (data: any) => apiClient.post('/payments/methods', data),
updateMethod: (id: string, data: any) => apiClient.patch(`/payments/methods/${id}`, data),
deleteMethod: (id: string) => apiClient.delete(`/payments/methods/${id}`),
supplementary: {
create: (data: { bookingRef: string; amountMinor: number; reason: string; notes?: string }) =>
apiClient.post<any>('/payments/supplementary', data),
getAll: async (params?: any) => {
const cleanParams = Object.fromEntries(
Object.entries(params || {}).filter(([, v]) => v !== '' && v !== undefined && v !== null)
) as Record<string, string>;
const query = new URLSearchParams(cleanParams).toString();
const response = await apiClient.get<any>(`/payments/supplementary${query ? `?${query}` : ''}`);
if ((response as any)?.data) return (response as any).data;
return response;
},
markPaid: (id: string, providerTxnId?: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/mark-paid`, { providerTxnId }),
waive: (id: string, notes?: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/waive`, { notes }),
resend: (id: string) =>
apiClient.post<any>(`/payments/supplementary/${id}/resend`, {}),
},
};
// Tickets API