mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 09:58:12 +00:00
Payment discrepancy report updates
This commit is contained in:
@@ -47,6 +47,23 @@ export class ReportsController {
|
|||||||
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("payments")
|
||||||
|
@ApiOperation({ summary: "Payments collected for a schedule" })
|
||||||
|
getPaymentsReport(@Query('scheduleId') scheduleId: string) {
|
||||||
|
return this.service.getPaymentsReport(scheduleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("payments/discrepancy")
|
||||||
|
@ApiOperation({ summary: "Payment discrepancy breakdown for a schedule" })
|
||||||
|
getPaymentDiscrepancyBySchedule(
|
||||||
|
@Query('scheduleId') scheduleId: string,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
@Query('seatClass') seatClass?: string,
|
||||||
|
@Query('sort') sort?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getPaymentDiscrepancyBySchedule(scheduleId, { search, seatClass, sort });
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":reportId")
|
@Get(":reportId")
|
||||||
@ApiOperation({ summary: "Get report by ID" })
|
@ApiOperation({ summary: "Get report by ID" })
|
||||||
getReport(@Param("reportId") reportId: string) {
|
getReport(@Param("reportId") reportId: string) {
|
||||||
|
|||||||
@@ -843,6 +843,157 @@ export class ReportsService {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPaymentsReport(scheduleId: string) {
|
||||||
|
const bookings = await this.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId,
|
||||||
|
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||||
|
paymentIntent: { status: 'SUCCEEDED' },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
paymentIntent: { select: { amountMinor: true, currency: true, method: true, paidAt: true } },
|
||||||
|
seats: {
|
||||||
|
where: { leg: 1 },
|
||||||
|
select: {
|
||||||
|
passengerName: true,
|
||||||
|
fareMinor: true,
|
||||||
|
passengerCategory: true,
|
||||||
|
seatLabelSnapshot: true,
|
||||||
|
seat: { select: { coach: { select: { number: true, coachType: { select: { name: true } } } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passenger: { select: { user: { select: { phone: true, fullName: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = bookings.map(b => {
|
||||||
|
const actualMinor = b.seats.reduce((s, seat) => s + (seat.fareMinor ?? 0), 0);
|
||||||
|
const paidMinor = Math.round(b.paymentIntent!.amountMinor);
|
||||||
|
return {
|
||||||
|
bookingRef: b.bookingRef,
|
||||||
|
passengerName: b.seats[0]?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||||
|
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||||
|
method: b.paymentIntent!.method,
|
||||||
|
paidAt: b.paymentIntent!.paidAt,
|
||||||
|
actualMinor,
|
||||||
|
paidMinor,
|
||||||
|
currency: 'ETB',
|
||||||
|
passengerCount: b.seats.length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalActualMinor = rows.reduce((s, r) => s + r.actualMinor, 0);
|
||||||
|
const totalPaidMinor = rows.reduce((s, r) => s + r.paidMinor, 0);
|
||||||
|
|
||||||
|
const byMethod = rows.reduce((acc, r) => {
|
||||||
|
acc[r.method] = (acc[r.method] ?? 0) + r.paidMinor;
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, number>);
|
||||||
|
|
||||||
|
return { totalActualMinor, totalPaidMinor, byMethod, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentDiscrepancyBySchedule(scheduleId: string, params: {
|
||||||
|
search?: string;
|
||||||
|
seatClass?: string;
|
||||||
|
sort?: string;
|
||||||
|
}) {
|
||||||
|
const bookings = await this.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
scheduleId,
|
||||||
|
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||||
|
paymentIntent: { status: 'SUCCEEDED' },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
paymentIntent: { select: { amountMinor: true, currency: true } },
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
originStation: { select: { name: true } },
|
||||||
|
destinationStation: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seats: {
|
||||||
|
where: { leg: 1 },
|
||||||
|
orderBy: [
|
||||||
|
{ seat: { coach: { number: 'asc' as const } } },
|
||||||
|
{ seat: { seatNumber: 'asc' as const } },
|
||||||
|
],
|
||||||
|
select: {
|
||||||
|
passengerName: true,
|
||||||
|
passengerCategory: true,
|
||||||
|
seatLabelSnapshot: true,
|
||||||
|
fareMinor: true,
|
||||||
|
seat: {
|
||||||
|
select: {
|
||||||
|
seatNumber: true,
|
||||||
|
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passenger: { select: { user: { select: { phone: true, fullName: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
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 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 firstSeat = b.seats[0];
|
||||||
|
return {
|
||||||
|
bookingRef: b.bookingRef,
|
||||||
|
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,
|
||||||
|
paidMinor,
|
||||||
|
varianceMinor,
|
||||||
|
breakdown,
|
||||||
|
};
|
||||||
|
}).filter(r => r.varianceMinor > 0);
|
||||||
|
|
||||||
|
if (params.search?.trim()) {
|
||||||
|
const q = params.search.trim().toUpperCase();
|
||||||
|
rows = rows.filter(r => r.bookingRef.toUpperCase().includes(q));
|
||||||
|
}
|
||||||
|
if (params.seatClass?.trim()) {
|
||||||
|
const sc = params.seatClass.trim().toLowerCase();
|
||||||
|
rows = rows.filter(r => r.breakdown.some(b => b.seatClass.toLowerCase().includes(sc)));
|
||||||
|
}
|
||||||
|
if (params.sort === 'asc') {
|
||||||
|
rows.sort((a, b) => a.varianceMinor - b.varianceMinor);
|
||||||
|
} else {
|
||||||
|
rows.sort((a, b) => b.varianceMinor - a.varianceMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { total: rows.length, rows };
|
||||||
|
}
|
||||||
|
|
||||||
async getReport(reportId: string) {
|
async getReport(reportId: string) {
|
||||||
return this.prisma.operationalReport.findUnique({
|
return this.prisma.operationalReport.findUnique({
|
||||||
where: { id: reportId },
|
where: { id: reportId },
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export default function PassengersReportPage() {
|
|||||||
const [filterCoach, setFilterCoach] = useState("");
|
const [filterCoach, setFilterCoach] = useState("");
|
||||||
const [filterOrigin, setFilterOrigin] = useState("");
|
const [filterOrigin, setFilterOrigin] = useState("");
|
||||||
const [filterSeatClass, setFilterSeatClass] = useState("");
|
const [filterSeatClass, setFilterSeatClass] = useState("");
|
||||||
|
const [filterCoachNumber, setFilterCoachNumber] = useState("");
|
||||||
|
|
||||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
||||||
ScheduleOption[]
|
ScheduleOption[]
|
||||||
@@ -110,10 +111,12 @@ export default function PassengersReportPage() {
|
|||||||
const originOptions = [
|
const originOptions = [
|
||||||
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
|
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
|
||||||
].sort() as string[];
|
].sort() as string[];
|
||||||
|
const coachNumberOptions = coachOptions;
|
||||||
|
|
||||||
const filteredList = passengerList
|
const filteredList = passengerList
|
||||||
.filter((p) => {
|
.filter((p) => {
|
||||||
if (filterCoach && p.coachNumber !== filterCoach) return false;
|
if (filterCoach && p.coachNumber !== filterCoach) return false;
|
||||||
|
if (filterCoachNumber && p.coachNumber !== filterCoachNumber) return false;
|
||||||
if (filterOrigin && p.origin !== filterOrigin) return false;
|
if (filterOrigin && p.origin !== filterOrigin) return false;
|
||||||
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
|
if (filterSeatClass && p.seatClassName !== filterSeatClass) return false;
|
||||||
if (listSearch.trim()) {
|
if (listSearch.trim()) {
|
||||||
@@ -209,7 +212,9 @@ export default function PassengersReportPage() {
|
|||||||
setTab("occupancy");
|
setTab("occupancy");
|
||||||
setListSearch("");
|
setListSearch("");
|
||||||
setFilterCoach("");
|
setFilterCoach("");
|
||||||
|
setFilterCoachNumber("");
|
||||||
setFilterOrigin("");
|
setFilterOrigin("");
|
||||||
|
setFilterSeatClass("");
|
||||||
}}
|
}}
|
||||||
disabled={loadingSchedules}
|
disabled={loadingSchedules}
|
||||||
>
|
>
|
||||||
@@ -477,6 +482,18 @@ export default function PassengersReportPage() {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
<select
|
||||||
|
className="input w-36"
|
||||||
|
value={filterCoachNumber}
|
||||||
|
onChange={(e) => setFilterCoachNumber(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All coaches</option>
|
||||||
|
{coachNumberOptions.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{c}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<select
|
<select
|
||||||
className="input w-36"
|
className="input w-36"
|
||||||
value={filterOrigin}
|
value={filterOrigin}
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export default function PaymentDiscrepancyPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
{rows.map((row, i) => {
|
{rows.filter(row => row.balanceMinor > 0).map((row, i) => {
|
||||||
const isExpanded = expandedPnr === row.pnr;
|
const isExpanded = expandedPnr === row.pnr;
|
||||||
const hasMultiple = row.passengerCount > 1;
|
const hasMultiple = row.passengerCount > 1;
|
||||||
return (
|
return (
|
||||||
@@ -364,7 +364,7 @@ export default function PaymentDiscrepancyPage() {
|
|||||||
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
{fmtMoney(row.paidMinor, row.paidCurrency)}
|
{fmtMoney(row.paidMinor, row.paidCurrency)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 whitespace-nowrap">
|
<td className="px-4 py-3 whitespace-nowrap text-red-600 dark:text-red-400 font-semibold">
|
||||||
<BalanceBadge row={row} />
|
<BalanceBadge row={row} />
|
||||||
</td>
|
</td>
|
||||||
{/* Passengers column */}
|
{/* Passengers column */}
|
||||||
@@ -448,7 +448,7 @@ export default function PaymentDiscrepancyPage() {
|
|||||||
</div>
|
</div>
|
||||||
{!isSearchMode && (
|
{!isSearchMode && (
|
||||||
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400 dark:text-gray-500">
|
<div className="px-4 py-3 border-t border-gray-100 dark:border-gray-800 text-xs text-gray-400 dark:text-gray-500">
|
||||||
{rows.length} record{rows.length !== 1 ? 's' : ''} — click a phone number to call directly, or export CSV for bulk follow-up
|
{rows.filter(r => r.balanceMinor > 0).length} record{rows.filter(r => r.balanceMinor > 0).length !== 1 ? 's' : ''} — click a phone number to call directly, or export CSV for bulk follow-up
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function PaymentsReportLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,536 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
CreditCard, 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 PaymentRow {
|
||||||
|
bookingRef: string;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscrepancyRow {
|
||||||
|
bookingRef: string;
|
||||||
|
seatClass: string;
|
||||||
|
coachNumber: string | null;
|
||||||
|
seatNumber: string | null;
|
||||||
|
origin: string;
|
||||||
|
destination: string;
|
||||||
|
phone: string;
|
||||||
|
actualMinor: number;
|
||||||
|
paidMinor: number;
|
||||||
|
varianceMinor: number;
|
||||||
|
breakdown: BreakdownEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscrepancyReport { total: number; rows: DiscrepancyRow[]; }
|
||||||
|
|
||||||
|
// ── Pagination ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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);
|
||||||
|
const slice = items.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE);
|
||||||
|
return { page: safePage, setPage, totalPages, slice };
|
||||||
|
}
|
||||||
|
|
||||||
|
function Pagination({ page, totalPages, setPage, total }: {
|
||||||
|
page: number; totalPages: number; setPage: (p: number) => void; total: number;
|
||||||
|
}) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
const from = (page - 1) * PAGE_SIZE + 1;
|
||||||
|
const to = Math.min(page * PAGE_SIZE, total);
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-1 pt-3 border-t border-border text-xs text-muted-foreground">
|
||||||
|
<span>{from}–{to} of {total}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page - 1)}
|
||||||
|
disabled={page === 1}
|
||||||
|
className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="px-2">Page {page} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setPage(page + 1)}
|
||||||
|
disabled={page === totalPages}
|
||||||
|
className="p-1 rounded hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmt(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());
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Payments tab ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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 }) {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [seatClass, setSeatClass] = useState('');
|
||||||
|
const [sort, setSort] = useState<'desc' | 'asc'>('desc');
|
||||||
|
const [expandedRef, setExpandedRef] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data, isLoading, isError } = useQuery<DiscrepancyReport>({
|
||||||
|
queryKey: ['payments-discrepancy', scheduleId, search, seatClass, sort],
|
||||||
|
queryFn: () => apiClient.get('/reports/payments/discrepancy', {
|
||||||
|
params: { scheduleId, search: search || undefined, seatClass: seatClass || undefined, sort },
|
||||||
|
}),
|
||||||
|
enabled: !!scheduleId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const pg = usePagination(data?.rows ?? [], `${scheduleId}-${search}-${seatClass}-${sort}`);
|
||||||
|
|
||||||
|
const seatClassOptions = useMemo(() => {
|
||||||
|
if (!data) return [];
|
||||||
|
return [...new Set(data.rows.flatMap(r => r.breakdown.map(b => b.seatClass)))].sort();
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const doExport = () => {
|
||||||
|
if (!data) return;
|
||||||
|
const headers = ['Booking Ref', 'Route', 'Seat Class', 'Coach', 'Seat', 'Phone', 'Actual (ETB)', 'Paid (ETB)', 'Variance (ETB)'];
|
||||||
|
const rows = data.rows.map(r => [
|
||||||
|
r.bookingRef,
|
||||||
|
`${r.origin} → ${r.destination}`,
|
||||||
|
r.seatClass,
|
||||||
|
r.coachNumber ?? '—',
|
||||||
|
r.seatNumber ?? '—',
|
||||||
|
r.phone,
|
||||||
|
(r.actualMinor / 100).toFixed(2),
|
||||||
|
(r.paidMinor / 100).toFixed(2),
|
||||||
|
(r.varianceMinor / 100).toFixed(2),
|
||||||
|
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Schedule selector */}
|
||||||
|
<div className="card">
|
||||||
|
<div className="flex items-end gap-4 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-72">
|
||||||
|
<label className="label">Schedule</label>
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={scheduleId}
|
||||||
|
onChange={e => { setScheduleId(e.target.value); setTab('payments'); }}
|
||||||
|
disabled={loadingSchedules}
|
||||||
|
>
|
||||||
|
<option value="">{loadingSchedules ? 'Loading schedules…' : 'Select a schedule…'}</option>
|
||||||
|
{schedules.map(s => <option key={s.id} value={s.id}>{s.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{scheduleId ? (
|
||||||
|
<>
|
||||||
|
{/* Schedule info banner */}
|
||||||
|
<div className="card flex items-center gap-4">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="border-b border-border flex">
|
||||||
|
{tabs.map(t => (
|
||||||
|
<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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.key === 'discrepancy' && <CreditCard className="w-3.5 h-3.5 inline mr-1.5 opacity-70" />}
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{tab === 'payments' && <PaymentsTab scheduleId={scheduleId} />}
|
||||||
|
{tab === 'discrepancy' && <DiscrepancyTab scheduleId={scheduleId} />}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,9 +121,10 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
|||||||
{
|
{
|
||||||
title: 'Analytics & Reports',
|
title: 'Analytics & Reports',
|
||||||
items: [
|
items: [
|
||||||
{ name: 'Overall', href: '/reports', icon: BarChart3, permission: PERMS.reports.view },
|
{ name: 'Overall', href: '/reports/overall', icon: BarChart3, permission: PERMS.reports.view },
|
||||||
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
{ name: 'Seats', href: '/reports/seats', icon: Armchair, permission: PERMS.reports.view },
|
||||||
{ name: 'Passengers', href: '/reports/passengers', icon: Users, 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 },
|
// { name: 'Operational Reports', href: '/operational-reports', icon: FileText, permission: PERMS.reports.view },
|
||||||
]
|
]
|
||||||
@@ -221,11 +222,12 @@ export default function Sidebar() {
|
|||||||
// Special handling for Settings to avoid conflict with User Management
|
// Special handling for Settings to avoid conflict with User Management
|
||||||
let isActive;
|
let isActive;
|
||||||
if (item.href === '/settings') {
|
if (item.href === '/settings') {
|
||||||
// Settings is active only for exact match or non-users sub-routes
|
isActive = pathname === '/settings' ||
|
||||||
isActive = pathname === '/settings' ||
|
|
||||||
(pathname?.startsWith('/settings/') && !pathname.startsWith('/settings/users'));
|
(pathname?.startsWith('/settings/') && !pathname.startsWith('/settings/users'));
|
||||||
|
} else if (item.href === '/payments') {
|
||||||
|
// Exact match only — avoid colliding with /reports/payments
|
||||||
|
isActive = pathname === '/payments' || pathname?.startsWith('/payments/');
|
||||||
} else {
|
} else {
|
||||||
// Standard matching for other items
|
|
||||||
isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
|
|||||||
Reference in New Issue
Block a user