This commit is contained in:
Roba Boru
2026-07-20 23:08:47 +03:00
3 changed files with 220 additions and 389 deletions

View File

@@ -412,20 +412,27 @@ export class ReportsService {
}
async listSchedulesForPicker() {
const now = new Date();
const schedules = await this.prisma.trainSchedule.findMany({
where: { departureAt: { gte: now } },
select: {
id: true,
departureAt: true,
isPackageOnly: true,
train: { select: { number: true } },
originStation: { select: { name: true } },
destinationStation: { select: { name: true } },
},
orderBy: { departureAt: "desc" },
orderBy: { departureAt: 'asc' },
take: 200,
});
return schedules.map((s) => ({
id: s.id,
label: `${s.train.number} · ${s.originStation.name}${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString("en-GB", { dateStyle: "medium", timeStyle: "short" })}`,
departureAt: s.departureAt,
isPackage: s.isPackageOnly,
label: `${s.train.number} · ${s.originStation.name}${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}${
s.isPackageOnly ? ' (package)' : ''
}`,
}));
}
@@ -913,6 +920,7 @@ export class ReportsService {
destinationStation: { select: { name: true } },
},
},
package: { select: { id: true } },
seats: {
where: { leg: 1 },
orderBy: [
@@ -927,7 +935,8 @@ export class ReportsService {
seat: {
select: {
seatNumber: true,
coach: { select: { number: true, coachType: { select: { name: true } } } },
bedPosition: true,
coach: { select: { number: true, coachType: { select: { name: true, seatClasses: { select: { name: true, bedPosition: true } } } } } },
},
},
},
@@ -936,47 +945,48 @@ export class ReportsService {
},
});
const resolveSeatClass = (seat: any): string => {
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 ?? 'Unknown';
};
let rows = bookings.map(b => {
const pi = b.paymentIntent!;
const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0);
const paidMinor = Math.round(pi.amountMinor);
const varianceMinor = actualMinor - paidMinor;
// Per-seat-class breakdown
const byClass = new Map<string, { seatClass: string; coachNumber: string | null; seatNumber: string | null; fareMinor: number }[]>();
for (const s of b.seats) {
const key = s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? 'Unknown';
if (!byClass.has(key)) byClass.set(key, []);
byClass.get(key)!.push({
seatClass: key,
coachNumber: s.seat?.coach?.number ?? null,
seatNumber: s.seat?.seatNumber ?? null,
fareMinor: s.fareMinor ?? 0,
});
}
const isPackage = !!(b as any).package;
const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor;
const effectiveVarianceMinor = effectiveActualMinor - paidMinor;
const breakdown = [...byClass.entries()].map(([seatClass, seats]) => ({
seatClass,
seats: seats.map(s => ({ coachNumber: s.coachNumber, seatNumber: s.seatNumber })),
totalFareMinor: seats.reduce((s, x) => s + x.fareMinor, 0),
count: seats.length,
const breakdown = b.seats.map(s => ({
passengerName: s.passengerName ?? '—',
seatClass: resolveSeatClass(s.seat),
coachNumber: s.seat?.coach?.number ?? null,
seatNumber: s.seat?.seatNumber ?? null,
fareMinor: isPackage ? (s.fareMinor ?? 0) * 2 : (s.fareMinor ?? 0),
}));
const firstSeat = b.seats[0];
return {
bookingRef: b.bookingRef,
isPackage,
seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
coachNumber: firstSeat?.seat?.coach?.number ?? null,
seatNumber: firstSeat?.seat?.seatNumber ?? null,
origin: b.schedule.originStation.name,
destination: b.schedule.destinationStation.name,
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
actualMinor,
actualMinor: effectiveActualMinor,
paidMinor,
varianceMinor,
varianceMinor: effectiveVarianceMinor,
breakdown,
};
}).filter(r => r.varianceMinor > 0);
}).filter(r => r.varianceMinor > 0 && r.paidMinor > 0);
if (params.search?.trim()) {
const q = params.search.trim().toUpperCase();

View File

@@ -3,44 +3,26 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
CreditCard, AlertTriangle, Train, Download, Search, X,
AlertTriangle, Train, Download, Search, X,
ChevronDown, ChevronUp, Loader2, ChevronLeft, ChevronRight,
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import { formatDateTime } from '@/lib/utils';
// ── Types ─────────────────────────────────────────────────────────────────────
interface ScheduleOption { id: string; label: string; }
interface ScheduleOption { id: string; label: string; departureAt: string; isPackage: boolean; }
interface PaymentRow {
bookingRef: string;
interface PassengerBreakdown {
passengerName: string;
phone: string;
method: string;
paidAt: string | null;
actualMinor: number;
paidMinor: number;
currency: string;
passengerCount: number;
}
interface PaymentsReport {
totalActualMinor: number;
totalPaidMinor: number;
byMethod: Record<string, number>;
rows: PaymentRow[];
}
interface BreakdownEntry {
seatClass: string;
seats: { coachNumber: string | null; seatNumber: string | null }[];
totalFareMinor: number;
count: number;
coachNumber: string | null;
seatNumber: string | null;
fareMinor: number;
}
interface DiscrepancyRow {
bookingRef: string;
isPackage: boolean;
seatClass: string;
coachNumber: string | null;
seatNumber: string | null;
@@ -50,7 +32,7 @@ interface DiscrepancyRow {
actualMinor: number;
paidMinor: number;
varianceMinor: number;
breakdown: BreakdownEntry[];
breakdown: PassengerBreakdown[];
}
interface DiscrepancyReport { total: number; rows: DiscrepancyRow[]; }
@@ -61,7 +43,6 @@ const PAGE_SIZE = 20;
function usePagination<T>(items: T[], resetKey?: unknown) {
const [page, setPage] = useState(1);
// reset to page 1 whenever resetKey changes (e.g. new data loaded)
useMemo(() => { setPage(1); }, [resetKey]); // eslint-disable-line react-hooks/exhaustive-deps
const totalPages = Math.max(1, Math.ceil(items.length / PAGE_SIZE));
const safePage = Math.min(page, totalPages);
@@ -101,12 +82,13 @@ function Pagination({ page, totalPages, setPage, total }: {
// ── Helpers ───────────────────────────────────────────────────────────────────
function fmt(minor: number) {
function fmtMinor(minor: number) {
return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
function methodLabel(m: string) {
return m.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
// paidMinor from PaymentIntent.amountMinor is a Float stored as full units (not cents)
function fmtPaid(amount: number) {
return `ETB ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
}
function downloadCsv(csv: string, filename: string) {
@@ -117,134 +99,25 @@ function downloadCsv(csv: string, filename: string) {
URL.revokeObjectURL(url);
}
// ── Payments tab ──────────────────────────────────────────────────────────────
// ── Discrepancy page ──────────────────────────────────────────────────────────
function PaymentsTab({ scheduleId }: { scheduleId: string }) {
const { data, isLoading, isError } = useQuery<PaymentsReport>({
queryKey: ['payments-report', scheduleId],
queryFn: () => apiClient.get(`/reports/payments?scheduleId=${scheduleId}`),
enabled: !!scheduleId,
});
const pg = usePagination(data?.rows ?? [], scheduleId);
const doExport = () => {
if (!data) return;
const headers = ['Booking Ref', 'Passenger', 'Phone', 'Method', 'Paid At', 'Actual (ETB)', 'Paid (ETB)', 'Passengers'];
const rows = data.rows.map(r => [
r.bookingRef,
r.passengerName,
r.phone,
methodLabel(r.method),
r.paidAt ? new Date(r.paidAt).toLocaleString('en-GB') : '—',
(r.actualMinor / 100).toFixed(2),
(r.paidMinor / 100).toFixed(2),
String(r.passengerCount),
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
downloadCsv([headers.join(','), ...rows].join('\n'), `payments-${scheduleId}.csv`);
};
if (!scheduleId) return null;
if (isLoading) return <div className="flex items-center gap-2 text-sm text-muted-foreground py-8"><Loader2 className="w-4 h-4 animate-spin" />Loading</div>;
if (isError) return <p className="text-sm text-red-500 py-4">Failed to load payments data.</p>;
if (!data) return null;
const totalVarianceMinor = data.totalActualMinor - data.totalPaidMinor;
return (
<div className="space-y-6">
{/* Summary cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ label: 'Total Fare (Actual)', value: fmt(data.totalActualMinor), color: 'blue' },
{ label: 'Total Collected', value: fmt(data.totalPaidMinor), color: 'emerald' },
{
label: 'Total Variance',
value: fmt(Math.abs(totalVarianceMinor)),
color: totalVarianceMinor === 0 ? 'emerald' : 'red',
sub: totalVarianceMinor === 0 ? 'Fully collected' : totalVarianceMinor > 0 ? 'Under-collected' : 'Over-collected',
},
].map(({ label, value, color, sub }) => (
<div key={label} className="card flex flex-col gap-1">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{label}</p>
<p className={`text-2xl font-bold tabular-nums mt-1 text-${color}-600 dark:text-${color}-400`}>{value}</p>
{sub && <p className="text-xs text-muted-foreground">{sub}</p>}
</div>
))}
</div>
{/* By method */}
{Object.keys(data.byMethod).length > 0 && (
<div className="card">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground mb-4">By Payment Method</h3>
<div className="space-y-2">
{Object.entries(data.byMethod).sort(([, a], [, b]) => b - a).map(([method, minor]) => (
<div key={method} className="flex items-center justify-between text-sm">
<span className="font-medium">{methodLabel(method)}</span>
<span className="tabular-nums font-semibold">{fmt(minor)}</span>
</div>
))}
</div>
</div>
)}
{/* Rows table */}
<div className="card overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
Transactions ({data.rows.length})
</h3>
{data.rows.length > 0 && (
<button
onClick={doExport}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
)}
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
{['Booking Ref', 'Passenger', 'Phone', 'Method', 'Paid At', 'Actual', 'Paid', 'Pax'].map(h => (
<th key={h} className="pb-2 pr-4 whitespace-nowrap">{h}</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border">
{pg.slice.map(r => (
<tr key={r.bookingRef} className="hover:bg-muted/30">
<td className="py-2 pr-4 font-mono text-xs">{r.bookingRef}</td>
<td className="py-2 pr-4 font-medium whitespace-nowrap">{r.passengerName}</td>
<td className="py-2 pr-4 text-muted-foreground text-xs">{r.phone}</td>
<td className="py-2 pr-4 text-xs">{methodLabel(r.method)}</td>
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
{r.paidAt ? formatDateTime(r.paidAt) : '—'}
</td>
<td className="py-2 pr-4 tabular-nums text-xs">{fmt(r.actualMinor)}</td>
<td className="py-2 pr-4 tabular-nums text-xs font-semibold">{fmt(r.paidMinor)}</td>
<td className="py-2 tabular-nums text-xs text-center">{r.passengerCount}</td>
</tr>
))}
{data.rows.length === 0 && (
<tr><td colSpan={8} className="py-8 text-center text-sm text-muted-foreground">No payments found for this schedule.</td></tr>
)}
</tbody>
</table>
<Pagination page={pg.page} totalPages={pg.totalPages} setPage={pg.setPage} total={data.rows.length} />
</div>
</div>
);
}
// ── Discrepancy tab ───────────────────────────────────────────────────────────
function DiscrepancyTab({ scheduleId }: { scheduleId: string }) {
export default function PaymentsReportPage() {
const [scheduleId, setScheduleId] = useState('');
const [search, setSearch] = useState('');
const [seatClass, setSeatClass] = useState('');
const [sort, setSort] = useState<'desc' | 'asc'>('desc');
const [expandedRef, setExpandedRef] = useState<string | null>(null);
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ['report-schedules'],
queryFn: () => apiClient.get('/reports/schedules'),
});
const now = new Date();
const schedules = (schedulesRaw ?? []).filter(
s => new Date(s.departureAt) >= now,
);
const { data, isLoading, isError } = useQuery<DiscrepancyReport>({
queryKey: ['payments-discrepancy', scheduleId, search, seatClass, sort],
queryFn: () => apiClient.get('/reports/payments/discrepancy', {
@@ -277,201 +150,11 @@ function DiscrepancyTab({ scheduleId }: { scheduleId: string }) {
downloadCsv([headers.join(','), ...rows].join('\n'), `discrepancy-${scheduleId}.csv`);
};
if (!scheduleId) return null;
return (
<div className="space-y-4">
{/* Filters */}
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value.toUpperCase())}
placeholder="Booking ref…"
className="input pl-8 pr-7 font-mono text-sm w-full"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
<select
value={seatClass}
onChange={e => setSeatClass(e.target.value)}
className="input w-44 text-sm"
>
<option value="">All seat classes</option>
{seatClassOptions.map(sc => <option key={sc} value={sc}>{sc}</option>)}
</select>
<select
value={sort}
onChange={e => setSort(e.target.value as 'desc' | 'asc')}
className="input w-48 text-sm"
>
<option value="desc">Variance: High Low</option>
<option value="asc">Variance: Low High</option>
</select>
{data && data.rows.length > 0 && (
<button
onClick={doExport}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
)}
</div>
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8">
<Loader2 className="w-4 h-4 animate-spin" />Loading
</div>
)}
{isError && <p className="text-sm text-red-500 py-4">Failed to load discrepancy data.</p>}
{data && (
<div className="card overflow-x-auto">
<div className="flex items-center justify-between mb-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{data.total} discrepanc{data.total !== 1 ? 'ies' : 'y'} found
</h3>
{data.rows.length > 0 && (
<button
onClick={doExport}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
)}
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4 w-4" />
<th className="pb-2 pr-4 whitespace-nowrap">Booking Ref</th>
<th className="pb-2 pr-4 whitespace-nowrap">Seat Class · Coach · Seat</th>
<th className="pb-2 pr-4 whitespace-nowrap">Route</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Actual</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Paid</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Variance</th>
<th className="pb-2 whitespace-nowrap">Phone</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pg.slice.map(r => {
const isExpanded = expandedRef === r.bookingRef;
return (
<>
<tr
key={r.bookingRef}
className="hover:bg-muted/30 cursor-pointer"
onClick={() => setExpandedRef(isExpanded ? null : r.bookingRef)}
>
<td className="py-2 pr-2 text-muted-foreground">
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</td>
<td className="py-2 pr-4 font-mono text-xs font-semibold">{r.bookingRef}</td>
<td className="py-2 pr-4 text-xs">
<span className="font-medium">{r.seatClass}</span>
{r.coachNumber && <span className="text-muted-foreground"> · {r.coachNumber}</span>}
{r.seatNumber && <span className="text-muted-foreground"> · #{r.seatNumber}</span>}
</td>
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
{r.origin} {r.destination}
</td>
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmt(r.actualMinor)}</td>
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmt(r.paidMinor)}</td>
<td className="py-2 pr-4 text-right">
<span className="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-md bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
<AlertTriangle className="w-3 h-3" />
{fmt(r.varianceMinor)}
</span>
</td>
<td className="py-2 text-xs text-muted-foreground">{r.phone}</td>
</tr>
{/* Breakdown row */}
{isExpanded && (
<tr key={`${r.bookingRef}-breakdown`} className="bg-muted/20">
<td colSpan={8} className="px-6 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Fare breakdown by seat class
</p>
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted-foreground">
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seat Class</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seats</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Count</th>
<th className="pb-1.5 font-semibold uppercase tracking-wide text-right">Total Fare</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{r.breakdown.map((b, bi) => (
<tr key={bi} className="text-foreground">
<td className="py-1.5 pr-4 font-medium">{b.seatClass}</td>
<td className="py-1.5 pr-4 font-mono text-muted-foreground">
{b.seats.map(s => [s.coachNumber, s.seatNumber ? `#${s.seatNumber}` : null].filter(Boolean).join(' ')).join(', ') || '—'}
</td>
<td className="py-1.5 pr-4">{b.count}</td>
<td className="py-1.5 text-right tabular-nums font-semibold">{fmt(b.totalFareMinor)}</td>
</tr>
))}
<tr className="border-t border-border font-semibold">
<td colSpan={3} className="pt-2 text-muted-foreground">Total actual vs paid</td>
<td className="pt-2 text-right tabular-nums">
{fmt(r.actualMinor)} / {fmt(r.paidMinor)}
<span className="ml-2 text-red-500">
(+{fmt(r.varianceMinor)})
</span>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
)}
</>
);
})}
{data.rows.length === 0 && (
<tr><td colSpan={8} className="py-8 text-center text-sm text-muted-foreground">No discrepancies found.</td></tr>
)}
</tbody>
</table>
<Pagination page={pg.page} totalPages={pg.totalPages} setPage={pg.setPage} total={data.rows.length} />
</div>
)}
</div>
);
}
// ── Main page ─────────────────────────────────────────────────────────────────
type Tab = 'payments' | 'discrepancy';
export default function PaymentsReportPage() {
const [scheduleId, setScheduleId] = useState('');
const [tab, setTab] = useState<Tab>('payments');
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<ScheduleOption[]>({
queryKey: ['report-schedules'],
queryFn: () => apiClient.get('/reports/schedules'),
});
const schedules = schedulesRaw ?? [];
const tabs: { key: Tab; label: string }[] = [
{ key: 'payments', label: 'Payments Collected' },
{ key: 'discrepancy', label: 'Discrepancy' },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold text-foreground">Payments Report</h1>
<p className="text-muted-foreground mt-1">Payments collected and discrepancies for a schedule</p>
<p className="text-muted-foreground mt-1">Payment discrepancies for upcoming schedules</p>
</div>
{/* Schedule selector */}
@@ -482,7 +165,7 @@ export default function PaymentsReportPage() {
<select
className="input"
value={scheduleId}
onChange={e => { setScheduleId(e.target.value); setTab('payments'); }}
onChange={e => { setScheduleId(e.target.value); setSeatClass(''); setSearch(''); }}
disabled={loadingSchedules}
>
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
@@ -499,36 +182,174 @@ export default function PaymentsReportPage() {
<div className="rounded-lg bg-emerald-100 dark:bg-emerald-900/30 p-2.5">
<Train className="h-5 w-5 text-emerald-600 dark:text-emerald-400" />
</div>
<div>
<p className="font-semibold">{schedules.find(s => s.id === scheduleId)?.label ?? scheduleId}</p>
<p className="font-semibold">{schedules.find(s => s.id === scheduleId)?.label ?? scheduleId}</p>
</div>
{/* Filters */}
<div className="flex items-center gap-2 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value.toUpperCase())}
placeholder="Booking ref…"
className="input pl-8 pr-7 font-mono text-sm w-full"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground">
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Tabs */}
<div className="border-b border-border flex">
{tabs.map(t => (
<select
value={seatClass}
onChange={e => setSeatClass(e.target.value)}
className="input w-48 text-sm"
>
<option value="">All seat classes</option>
{seatClassOptions.map(sc => <option key={sc} value={sc}>{sc}</option>)}
</select>
<select
value={sort}
onChange={e => setSort(e.target.value as 'desc' | 'asc')}
className="input w-48 text-sm"
>
<option value="desc">Variance: High Low</option>
<option value="asc">Variance: Low High</option>
</select>
{data && data.rows.length > 0 && (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition-colors ${
tab === t.key
? 'border-emerald-500 text-emerald-600 dark:text-emerald-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
onClick={doExport}
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
>
{t.key === 'discrepancy' && <CreditCard className="w-3.5 h-3.5 inline mr-1.5 opacity-70" />}
{t.label}
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
))}
)}
</div>
{tab === 'payments' && <PaymentsTab scheduleId={scheduleId} />}
{tab === 'discrepancy' && <DiscrepancyTab scheduleId={scheduleId} />}
{isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground py-8">
<Loader2 className="w-4 h-4 animate-spin" />Loading
</div>
)}
{isError && <p className="text-sm text-red-500 py-4">Failed to load discrepancy data.</p>}
{data && (
<div className="card overflow-x-auto">
<div className="mb-4">
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
{data.total} discrepanc{data.total !== 1 ? 'ies' : 'y'} found
</h3>
</div>
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-xs text-muted-foreground uppercase tracking-wider">
<th className="pb-2 pr-4 w-4" />
<th className="pb-2 pr-4 whitespace-nowrap">Booking Ref</th>
<th className="pb-2 pr-4 whitespace-nowrap">Seat Class · Coach · Seat</th>
<th className="pb-2 pr-4 whitespace-nowrap">Route</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Actual</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Paid</th>
<th className="pb-2 pr-4 text-right whitespace-nowrap">Variance</th>
<th className="pb-2 whitespace-nowrap">Phone</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{pg.slice.map(r => {
const isExpanded = expandedRef === r.bookingRef;
return (
<>
<tr
key={r.bookingRef}
className="hover:bg-muted/30 cursor-pointer"
onClick={() => setExpandedRef(isExpanded ? null : r.bookingRef)}
>
<td className="py-2 pr-2 text-muted-foreground">
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</td>
<td className="py-2 pr-4 font-mono text-xs font-semibold">
{r.bookingRef}
{r.isPackage && (
<span className="ml-1.5 text-[10px] font-semibold bg-purple-100 dark:bg-purple-950/40 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800 px-1.5 py-0.5 rounded">
package
</span>
)}
</td>
<td className="py-2 pr-4 text-xs">
<span className="font-medium">{r.seatClass}</span>
{r.coachNumber && <span className="text-muted-foreground"> · {r.coachNumber}</span>}
{r.seatNumber && <span className="text-muted-foreground"> · #{r.seatNumber}</span>}
</td>
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
{r.origin} {r.destination}
</td>
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmtMinor(r.actualMinor)}</td>
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmtPaid(r.paidMinor)}</td>
<td className="py-2 pr-4 text-right">
<span className="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-md bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
<AlertTriangle className="w-3 h-3" />
{fmtMinor(r.varianceMinor)}
</span>
</td>
<td className="py-2 text-xs text-muted-foreground">{r.phone}</td>
</tr>
{/* Fare breakdown */}
{isExpanded && (
<tr key={`${r.bookingRef}-breakdown`} className="bg-muted/20">
<td colSpan={8} className="px-6 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
Fare breakdown
</p>
<table className="w-full text-xs">
<thead>
<tr className="text-left text-muted-foreground border-b border-border/50">
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Passenger</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seat Class</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Coach</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seat</th>
<th className="pb-1.5 font-semibold uppercase tracking-wide text-right">Actual Fare</th>
</tr>
</thead>
<tbody className="divide-y divide-border/50">
{r.breakdown.map((b, bi) => (
<tr key={bi} className="text-foreground">
<td className="py-1.5 pr-4 font-medium whitespace-nowrap">{b.passengerName}</td>
<td className="py-1.5 pr-4">{b.seatClass}</td>
<td className="py-1.5 pr-4 font-mono">{b.coachNumber ?? '—'}</td>
<td className="py-1.5 pr-4 font-mono">{b.seatNumber ?? '—'}</td>
<td className="py-1.5 text-right tabular-nums font-semibold">{fmtMinor(b.fareMinor)}</td>
</tr>
))}
<tr className="border-t border-border font-semibold">
<td colSpan={4} className="pt-2 text-muted-foreground">Total actual vs paid</td>
<td className="pt-2 text-right tabular-nums">
{fmtMinor(r.actualMinor)} / {fmtPaid(r.paidMinor)}
<span className="ml-2 text-red-500">(+{fmtMinor(r.varianceMinor)})</span>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
)}
</>
);
})}
{data.rows.length === 0 && (
<tr><td colSpan={8} className="py-8 text-center text-sm text-muted-foreground">No discrepancies found.</td></tr>
)}
</tbody>
</table>
<Pagination page={pg.page} totalPages={pg.totalPages} setPage={pg.setPage} total={data.rows.length} />
</div>
)}
</>
) : (
<div className="card py-16 text-center text-muted-foreground">
<CreditCard className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a schedule above to load the payments report</p>
<AlertTriangle className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p>Select a schedule above to load the discrepancy report</p>
</div>
)}
</div>

View File

@@ -125,7 +125,7 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
{ name: 'Passengers', href: '/reports/passengers', icon: Users, permission: PERMS.reports.view },
{ name: 'Payments', href: '/reports/payments', icon: CreditCard, permission: PERMS.reports.view },
{ name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
// { name: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, permission: PERMS.reports.view },
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
]
},