mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 01:48:12 +00:00
Merge pull request #839 from Tria-plc/alpha
Added payment discrepancy report
This commit is contained in:
@@ -36,6 +36,17 @@ export class ReportsController {
|
|||||||
return this.service.getOccupancyBySchedule(scheduleId);
|
return this.service.getOccupancyBySchedule(scheduleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get("payment-discrepancy")
|
||||||
|
@ApiOperation({ summary: "Payment discrepancy report — bookings where paid amount is less than the fare. Pass `search` to look up a specific PNR or ticket number." })
|
||||||
|
getPaymentDiscrepancy(
|
||||||
|
@Query('from') from?: string,
|
||||||
|
@Query('to') to?: string,
|
||||||
|
@Query('sortBy') sortBy?: string,
|
||||||
|
@Query('search') search?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getPaymentDiscrepancyReport({ from, to, sortBy, search });
|
||||||
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
|
|||||||
@@ -591,6 +591,226 @@ export class ReportsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPaymentDiscrepancyReport(params: {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
search?: string;
|
||||||
|
}) {
|
||||||
|
// Load exchange rates once — we need DJF→ETB (and any other non-ETB currencies).
|
||||||
|
// Keep only the most-recent rate per pair (rates are ordered desc by effectiveDate).
|
||||||
|
const rateRows = await this.prisma.currencyExchangeRate.findMany({
|
||||||
|
where: { toCurrency: 'ETB' as any },
|
||||||
|
orderBy: { effectiveDate: 'desc' },
|
||||||
|
});
|
||||||
|
const rateToEtb = new Map<string, number>();
|
||||||
|
for (const r of rateRows) {
|
||||||
|
if (!rateToEtb.has(r.fromCurrency)) {
|
||||||
|
rateToEtb.set(r.fromCurrency, Number(r.rate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert any minor amount to its ETB equivalent using stored exchange rates.
|
||||||
|
// b.totalMinor is the booking's canonical ETB amount (always stored in ETB),
|
||||||
|
// so callers should pass that directly rather than converting displayTotalMinor.
|
||||||
|
const toEtbMinor = (minor: number, currency: string): number => {
|
||||||
|
if (currency === 'ETB') return minor;
|
||||||
|
const rate = rateToEtb.get(currency);
|
||||||
|
// If no rate is on file fall back to the raw value (avoids silently hiding
|
||||||
|
// cross-currency bookings, at the cost of an approximate comparison).
|
||||||
|
return rate ? Math.round(minor * rate) : minor;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (params.search?.trim()) {
|
||||||
|
return this.getDiscrepancyForRef(params.search.trim(), toEtbMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateFilter: Record<string, Date> = {};
|
||||||
|
if (params.from) dateFilter.gte = new Date(params.from + 'T00:00:00.000Z');
|
||||||
|
if (params.to) dateFilter.lte = new Date(params.to + 'T23:59:59.999Z');
|
||||||
|
|
||||||
|
const bookings = await this.prisma.booking.findMany({
|
||||||
|
where: {
|
||||||
|
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
|
||||||
|
paymentIntent: { status: 'SUCCEEDED' },
|
||||||
|
...(Object.keys(dateFilter).length > 0 && { createdAt: dateFilter }),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true } },
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
originStation: { select: { name: true, code: true, city: true } },
|
||||||
|
destinationStation: { select: { name: true, code: true, city: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seats: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { leg: 'asc' },
|
||||||
|
select: {
|
||||||
|
passengerName: true,
|
||||||
|
seatLabelSnapshot: true,
|
||||||
|
seat: {
|
||||||
|
select: {
|
||||||
|
seatNumber: true,
|
||||||
|
bedPosition: true,
|
||||||
|
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passenger: {
|
||||||
|
select: { user: { select: { phone: true, fullName: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = bookings
|
||||||
|
.map(b => {
|
||||||
|
const pi = b.paymentIntent!;
|
||||||
|
|
||||||
|
// Display amounts shown to the passenger (may be in DJF).
|
||||||
|
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||||
|
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||||
|
|
||||||
|
const paidMinor = pi.amountMinor;
|
||||||
|
const paidCurrency = pi.currency;
|
||||||
|
|
||||||
|
// b.totalMinor is always in ETB. Convert the paid amount to ETB for an
|
||||||
|
// apples-to-apples comparison regardless of which currency was used at checkout.
|
||||||
|
const owedEtb = b.totalMinor;
|
||||||
|
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
||||||
|
const balanceMinor = owedEtb - paidEtb;
|
||||||
|
const balanceCurrency = 'ETB';
|
||||||
|
|
||||||
|
const firstSeat = b.seats[0];
|
||||||
|
return {
|
||||||
|
pnr: b.bookingRef,
|
||||||
|
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||||
|
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||||
|
bookingDate: b.createdAt,
|
||||||
|
origin: b.schedule.originStation,
|
||||||
|
destination: b.schedule.destinationStation,
|
||||||
|
departureAt: b.schedule.departureAt,
|
||||||
|
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||||
|
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||||
|
actualMinor,
|
||||||
|
actualCurrency,
|
||||||
|
paidMinor,
|
||||||
|
paidCurrency,
|
||||||
|
balanceMinor,
|
||||||
|
balanceCurrency,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(r => r.balanceMinor > 0);
|
||||||
|
|
||||||
|
if (params.sortBy === 'departure') {
|
||||||
|
rows.sort((a, b) => new Date(a.departureAt).getTime() - new Date(b.departureAt).getTime());
|
||||||
|
} else {
|
||||||
|
rows.sort((a, b) => b.balanceMinor - a.balanceMinor);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalBalanceEtbMinor = rows.reduce((sum, r) => sum + r.balanceMinor, 0);
|
||||||
|
|
||||||
|
return { total: rows.length, totalBalanceEtbMinor, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getDiscrepancyForRef(
|
||||||
|
search: string,
|
||||||
|
toEtbMinor: (minor: number, currency: string) => number,
|
||||||
|
) {
|
||||||
|
let bookingId: string | null = null;
|
||||||
|
const byPnr = await this.prisma.booking.findUnique({
|
||||||
|
where: { bookingRef: search.toUpperCase() },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (byPnr) {
|
||||||
|
bookingId = byPnr.id;
|
||||||
|
} else {
|
||||||
|
const ticket = await this.prisma.ticket.findFirst({
|
||||||
|
where: { barcodePayload: search },
|
||||||
|
select: { bookingId: true },
|
||||||
|
});
|
||||||
|
bookingId = ticket?.bookingId ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!bookingId) {
|
||||||
|
return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const b = await this.prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: {
|
||||||
|
paymentIntent: { select: { amountMinor: true, currency: true, paidAt: true, status: true } },
|
||||||
|
schedule: {
|
||||||
|
include: {
|
||||||
|
originStation: { select: { name: true, code: true, city: true } },
|
||||||
|
destinationStation: { select: { name: true, code: true, city: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
seats: {
|
||||||
|
take: 1,
|
||||||
|
orderBy: { leg: 'asc' },
|
||||||
|
select: {
|
||||||
|
passengerName: true,
|
||||||
|
seatLabelSnapshot: true,
|
||||||
|
seat: {
|
||||||
|
select: {
|
||||||
|
seatNumber: true,
|
||||||
|
bedPosition: true,
|
||||||
|
coach: { select: { number: true, coachType: { select: { name: true } } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passenger: {
|
||||||
|
select: { user: { select: { phone: true, fullName: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!b) return { total: 0, totalBalanceEtbMinor: 0, rows: [], notFound: true };
|
||||||
|
|
||||||
|
const pi = b.paymentIntent;
|
||||||
|
const actualMinor = b.displayTotalMinor ?? b.totalMinor;
|
||||||
|
const actualCurrency = (b.displayCurrency as string | null) ?? b.currency;
|
||||||
|
const paidMinor = pi?.amountMinor ?? 0;
|
||||||
|
const paidCurrency = pi?.currency ?? b.currency;
|
||||||
|
|
||||||
|
const owedEtb = b.totalMinor;
|
||||||
|
const paidEtb = toEtbMinor(paidMinor, paidCurrency);
|
||||||
|
const balanceMinor = owedEtb - paidEtb;
|
||||||
|
const balanceCurrency = 'ETB';
|
||||||
|
|
||||||
|
const firstSeat = b.seats[0];
|
||||||
|
const row = {
|
||||||
|
pnr: b.bookingRef,
|
||||||
|
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
|
||||||
|
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||||
|
bookingDate: b.createdAt,
|
||||||
|
origin: b.schedule.originStation,
|
||||||
|
destination: b.schedule.destinationStation,
|
||||||
|
departureAt: b.schedule.departureAt,
|
||||||
|
seatType: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||||
|
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||||
|
actualMinor,
|
||||||
|
actualCurrency,
|
||||||
|
paidMinor,
|
||||||
|
paidCurrency,
|
||||||
|
balanceMinor,
|
||||||
|
balanceCurrency,
|
||||||
|
bookingStatus: b.status,
|
||||||
|
paymentStatus: pi?.status ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: balanceMinor > 0 ? 1 : 0,
|
||||||
|
totalBalanceEtbMinor: balanceMinor > 0 ? balanceMinor : 0,
|
||||||
|
rows: [row],
|
||||||
|
notFound: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async getReport(reportId: string) {
|
async getReport(reportId: string) {
|
||||||
return this.prisma.operationalReport.findUnique({
|
return this.prisma.operationalReport.findUnique({
|
||||||
where: { id: reportId },
|
where: { id: reportId },
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ interface PassengerRow {
|
|||||||
seatClassName: string | null;
|
seatClassName: string | null;
|
||||||
coachNumber: string | null;
|
coachNumber: string | null;
|
||||||
coachType: string | null;
|
coachType: string | null;
|
||||||
|
coachSeat: string | null;
|
||||||
nationality: string | null;
|
nationality: string | null;
|
||||||
origin: string | null;
|
origin: string | null;
|
||||||
destination: string | null;
|
destination: string | null;
|
||||||
|
departureAt: string | null;
|
||||||
amountPaidMinor: number;
|
amountPaidMinor: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
isGroupBooking: boolean;
|
isGroupBooking: boolean;
|
||||||
@@ -73,6 +75,7 @@ export default function PassengersReportPage() {
|
|||||||
const [listSearch, setListSearch] = useState("");
|
const [listSearch, setListSearch] = useState("");
|
||||||
const [filterCoach, setFilterCoach] = useState("");
|
const [filterCoach, setFilterCoach] = useState("");
|
||||||
const [filterOrigin, setFilterOrigin] = useState("");
|
const [filterOrigin, setFilterOrigin] = useState("");
|
||||||
|
const [filterSeatClass, setFilterSeatClass] = useState("");
|
||||||
|
|
||||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
||||||
ScheduleOption[]
|
ScheduleOption[]
|
||||||
@@ -101,6 +104,9 @@ export default function PassengersReportPage() {
|
|||||||
const coachOptions = [
|
const coachOptions = [
|
||||||
...new Set(passengerList.map((p) => p.coachNumber).filter(Boolean)),
|
...new Set(passengerList.map((p) => p.coachNumber).filter(Boolean)),
|
||||||
].sort() as string[];
|
].sort() as string[];
|
||||||
|
const seatClassOptions = [
|
||||||
|
...new Set(passengerList.map((p) => p.seatClassName).filter(Boolean)),
|
||||||
|
].sort() as string[];
|
||||||
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[];
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function PaymentDiscrepancyLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
AlertTriangle, CheckCircle2, Download, Search,
|
||||||
|
Loader2, PhoneCall, RefreshCw, X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { apiClient } from '@/lib/api-client';
|
||||||
|
import DatePicker from '@/components/ui/DatePicker';
|
||||||
|
import { parse, isValid } from 'date-fns';
|
||||||
|
|
||||||
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Station {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
city: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscrepancyRow {
|
||||||
|
pnr: string;
|
||||||
|
passengerName: string;
|
||||||
|
phone: string;
|
||||||
|
bookingDate: string;
|
||||||
|
origin: Station;
|
||||||
|
destination: Station;
|
||||||
|
departureAt: string;
|
||||||
|
seatType: string;
|
||||||
|
coachNumber: string | null;
|
||||||
|
actualMinor: number;
|
||||||
|
actualCurrency: string;
|
||||||
|
paidMinor: number;
|
||||||
|
paidCurrency: string;
|
||||||
|
balanceMinor: number;
|
||||||
|
balanceCurrency: string;
|
||||||
|
bookingStatus?: string;
|
||||||
|
paymentStatus?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DiscrepancyReport {
|
||||||
|
total: number;
|
||||||
|
totalBalanceEtbMinor: number;
|
||||||
|
rows: DiscrepancyRow[];
|
||||||
|
notFound?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function fmtMoney(minor: number, currency: string) {
|
||||||
|
return `${currency} ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportCsv(rows: DiscrepancyRow[]) {
|
||||||
|
const headers = [
|
||||||
|
'PNR', 'Passenger Name', 'Phone', 'Booking Date', 'Route',
|
||||||
|
'Departure Time', 'Seat Type', 'Coach', 'Actual Price', 'Paid Amount', 'Balance',
|
||||||
|
];
|
||||||
|
const lines = rows.map(r => [
|
||||||
|
r.pnr,
|
||||||
|
r.passengerName,
|
||||||
|
r.phone,
|
||||||
|
new Date(r.bookingDate).toLocaleDateString('en-GB'),
|
||||||
|
`${r.origin.city || r.origin.name} → ${r.destination.city || r.destination.name}`,
|
||||||
|
new Date(r.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' }),
|
||||||
|
r.seatType,
|
||||||
|
r.coachNumber ?? '—',
|
||||||
|
`${r.actualCurrency} ${(r.actualMinor / 100).toFixed(2)}`,
|
||||||
|
`${r.paidCurrency} ${(r.paidMinor / 100).toFixed(2)}`,
|
||||||
|
`${r.balanceCurrency} ${(r.balanceMinor / 100).toFixed(2)}`,
|
||||||
|
].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','));
|
||||||
|
|
||||||
|
const csv = [headers.join(','), ...lines].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 = `payment-discrepancy-${new Date().toISOString().slice(0, 10)}.csv`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Balance badge ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function BalanceBadge({ row }: { row: DiscrepancyRow }) {
|
||||||
|
if (row.balanceMinor <= 0) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 font-medium text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-800 px-2 py-0.5 rounded-md text-xs">
|
||||||
|
<CheckCircle2 className="w-3 h-3 shrink-0" />
|
||||||
|
Fully paid
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1 font-bold text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-2 py-0.5 rounded-md text-xs">
|
||||||
|
<AlertTriangle className="w-3 h-3 shrink-0" />
|
||||||
|
{fmtMoney(row.balanceMinor, row.balanceCurrency)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type Applied = { from: string; to: string; sortBy: string; search: string };
|
||||||
|
|
||||||
|
export default function PaymentDiscrepancyPage() {
|
||||||
|
const [from, setFrom] = useState('');
|
||||||
|
const [to, setTo] = useState('');
|
||||||
|
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [applied, setApplied] = useState<Applied | null>(null);
|
||||||
|
|
||||||
|
const isSearchMode = !!(applied?.search);
|
||||||
|
|
||||||
|
const { data, isLoading, isError, refetch } = useQuery<DiscrepancyReport>({
|
||||||
|
queryKey: ['payment-discrepancy', applied],
|
||||||
|
queryFn: () =>
|
||||||
|
apiClient.get('/reports/payment-discrepancy', {
|
||||||
|
params: {
|
||||||
|
from: applied?.search ? undefined : (applied?.from || undefined),
|
||||||
|
to: applied?.search ? undefined : (applied?.to || undefined),
|
||||||
|
sortBy: applied?.search ? undefined : applied?.sortBy,
|
||||||
|
search: applied?.search || undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
enabled: applied !== null,
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSearch() {
|
||||||
|
setApplied({ from, to, sortBy, search: search.trim() });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
setSearch('');
|
||||||
|
setApplied(prev => prev ? { ...prev, search: '' } : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = data?.rows ?? [];
|
||||||
|
|
||||||
|
const parsedFrom = from ? parse(from, 'yyyy-MM-dd', new Date()) : undefined;
|
||||||
|
const fromDate = parsedFrom && isValid(parsedFrom) ? parsedFrom : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6 max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-red-100 dark:bg-red-950/40">
|
||||||
|
<AlertTriangle className="w-6 h-6 text-red-600 dark:text-red-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Payment Discrepancy Report</h1>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Bookings where the amount paid is less than the actual fare — flagged for follow-up
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters — single row */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl px-5 py-3">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
|
||||||
|
{/* PNR / Ticket search — grows to fill available space */}
|
||||||
|
<div className="relative flex-1 min-w-0">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value.toUpperCase())}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="PNR or ticket number…"
|
||||||
|
className="w-full pl-9 pr-8 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearch('')}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500 select-none shrink-0">or</span>
|
||||||
|
|
||||||
|
{/* Date range */}
|
||||||
|
<DatePicker
|
||||||
|
value={from}
|
||||||
|
onChange={setFrom}
|
||||||
|
placeholder="From"
|
||||||
|
disabled={!!search}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400 dark:text-gray-500 select-none shrink-0">–</span>
|
||||||
|
<DatePicker
|
||||||
|
value={to}
|
||||||
|
onChange={setTo}
|
||||||
|
placeholder="To"
|
||||||
|
disabled={!!search}
|
||||||
|
minDate={fromDate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Sort */}
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={e => setSortBy(e.target.value as any)}
|
||||||
|
disabled={!!search}
|
||||||
|
className="shrink-0 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
<option value="balance">Highest balance first</option>
|
||||||
|
<option value="departure">Earliest departure first</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{applied && (
|
||||||
|
<button
|
||||||
|
onClick={() => refetch()}
|
||||||
|
title="Refresh"
|
||||||
|
className="p-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{rows.length > 0 && !isSearchMode && (
|
||||||
|
<button
|
||||||
|
onClick={() => exportCsv(rows)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 text-sm font-medium hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
Export CSV
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{isLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Search className="w-4 h-4" />}
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{isError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-800 px-4 py-3 text-sm text-red-700 dark:text-red-400">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
Failed to load discrepancy data. Please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Not found */}
|
||||||
|
{data?.notFound && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 px-4 py-3 text-sm text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="w-4 h-4 shrink-0" />
|
||||||
|
No booking found for <span className="font-mono font-bold mx-1">{applied?.search}</span> — check the PNR or ticket number and try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary — date-range mode, only when there are results */}
|
||||||
|
{data && !isSearchMode && !data.notFound && data.total > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-6 rounded-xl border px-5 py-4 bg-red-50 dark:bg-red-950/20 border-red-200 dark:border-red-800">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-bold text-red-700 dark:text-red-300">{data.total}</span>
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Underpaid bookings</span>
|
||||||
|
</div>
|
||||||
|
{data.totalBalanceEtbMinor > 0 && (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-2xl font-bold text-red-700 dark:text-red-300">
|
||||||
|
{fmtMoney(data.totalBalanceEtbMinor, 'ETB')}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total outstanding (ETB)</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
|
||||||
|
{isSearchMode && (
|
||||||
|
<div className="px-5 py-3 border-b border-gray-100 dark:border-gray-800 flex items-center gap-2">
|
||||||
|
<span className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||||
|
Lookup result for
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm font-bold text-gray-900 dark:text-white bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
|
||||||
|
{applied?.search}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearSearch}
|
||||||
|
className="ml-auto text-xs text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
Clear lookup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/60">
|
||||||
|
{[
|
||||||
|
'Passenger', 'PNR', 'Booking Date', 'Route', 'Departure',
|
||||||
|
'Seat Type', 'Actual Price', 'Paid', 'Balance', 'Phone',
|
||||||
|
].map(h => (
|
||||||
|
<th
|
||||||
|
key={h}
|
||||||
|
className="px-4 py-3 text-left text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{h}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<tr
|
||||||
|
key={row.pnr + i}
|
||||||
|
className="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
|
{row.passengerName}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="font-mono text-xs bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-1.5 py-0.5 rounded">
|
||||||
|
{row.pnr}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
{new Date(row.bookingDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{row.origin.city || row.origin.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-gray-400 dark:text-gray-500 mx-1">→</span>
|
||||||
|
<span className="text-gray-900 dark:text-white font-medium">
|
||||||
|
{row.destination.city || row.destination.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-600 dark:text-gray-400 whitespace-nowrap">
|
||||||
|
{new Date(row.departureAt).toLocaleString('en-US', {
|
||||||
|
month: 'short', day: 'numeric',
|
||||||
|
hour: 'numeric', minute: '2-digit', hour12: true,
|
||||||
|
timeZone: 'Africa/Addis_Ababa',
|
||||||
|
})}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="text-gray-900 dark:text-white">{row.seatType}</div>
|
||||||
|
{row.coachNumber && (
|
||||||
|
<div className="text-xs text-gray-400 dark:text-gray-500">Coach {row.coachNumber}</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap font-medium">
|
||||||
|
{fmtMoney(row.actualMinor, row.actualCurrency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||||
|
{fmtMoney(row.paidMinor, row.paidCurrency)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
<BalanceBadge row={row} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 whitespace-nowrap">
|
||||||
|
{row.phone && row.phone !== '—' ? (
|
||||||
|
<a
|
||||||
|
href={`tel:${row.phone}`}
|
||||||
|
className="flex items-center gap-1.5 text-blue-600 dark:text-blue-400 hover:underline"
|
||||||
|
>
|
||||||
|
<PhoneCall className="w-3.5 h-3.5 shrink-0" />
|
||||||
|
{row.phone}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 dark:text-gray-500">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{!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">
|
||||||
|
{rows.length} record{rows.length !== 1 ? 's' : ''} — click a phone number to call directly, or export CSV for bulk follow-up
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state — date range returned nothing */}
|
||||||
|
{applied && !isSearchMode && !isLoading && !isError && rows.length === 0 && data && !data.notFound && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<CheckCircle2 className="w-12 h-12 text-green-400 mb-3" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">No underpaid bookings found for this period</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Initial state */}
|
||||||
|
{!applied && !isLoading && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||||
|
<Search className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-3" />
|
||||||
|
<p className="text-gray-500 dark:text-gray-400">Enter a PNR or ticket number above, or select a date range to generate the report</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,48 +4,41 @@ import { useState, useMemo } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
Download,
|
Download,
|
||||||
Armchair,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
Clock,
|
Clock,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Ban,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { bookingsApi, seatsApi } from "@/lib/api";
|
import { bookingsApi, seatsApi } from "@/lib/api";
|
||||||
import Badge from "@/components/ui/Badge";
|
import Badge from "@/components/ui/Badge";
|
||||||
import ActionButton from "@/components/ui/ActionButton";
|
import ActionButton from "@/components/ui/ActionButton";
|
||||||
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
import { formatDateTime, formatCurrency } from "@/lib/utils";
|
||||||
|
|
||||||
interface ScheduleOption { id: string; label: string; }
|
const HOLD_DURATION_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
|
function isExpired(releaseAt: string | null): boolean {
|
||||||
|
if (!releaseAt) return false;
|
||||||
|
return new Date(releaseAt) < new Date();
|
||||||
|
}
|
||||||
|
|
||||||
interface BookedSeatRow {
|
interface BookedSeatRow {
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
passengerName: string;
|
passengerName: string;
|
||||||
passengerCategory: string;
|
coachNumber: string;
|
||||||
coachNumber: string | null;
|
seatNumber: string;
|
||||||
seatNumber: string | null;
|
|
||||||
seatClassName: string | null;
|
|
||||||
fareMinor: number;
|
fareMinor: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
bookingStatus: string;
|
bookingStatus: string;
|
||||||
paymentStatus: string;
|
paymentStatus: string;
|
||||||
bookedAt: string;
|
bookedAt: string;
|
||||||
}
|
releaseAt: string | null;
|
||||||
|
scheduleOrigin: string;
|
||||||
interface BlockedSeatRow {
|
scheduleDestination: string;
|
||||||
id: string;
|
scheduleDeparture: string;
|
||||||
coachNumber: string | null;
|
|
||||||
seatNumber: string | null;
|
|
||||||
seatClassName: string | null;
|
|
||||||
reason: string;
|
|
||||||
blockedBy: string;
|
|
||||||
blockedAt: string;
|
|
||||||
unblockAt: string | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReleaseAt(booking: any, seat: any): string | null {
|
function getReleaseAt(booking: any, seat: any): string | null {
|
||||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||||
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED")
|
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED") return null;
|
||||||
return null;
|
|
||||||
if (booking.status === "CONFIRMED") return null;
|
if (booking.status === "CONFIRMED") return null;
|
||||||
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
|
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
|
||||||
if (booking.createdAt) {
|
if (booking.createdAt) {
|
||||||
@@ -56,20 +49,8 @@ function getReleaseAt(booking: any, seat: any): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function csvEscape(v: string) { return `"${String(v).replace(/"/g, '""')}"`; }
|
|
||||||
|
|
||||||
function downloadCsv(csv: string, filename: string) {
|
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url; a.download = filename; a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SeatStatusReportPage() {
|
export default function SeatStatusReportPage() {
|
||||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">(
|
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
|
||||||
"ALL",
|
|
||||||
);
|
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
|
||||||
const { data: blockedSeats = [] } = useQuery({
|
const { data: blockedSeats = [] } = useQuery({
|
||||||
@@ -79,21 +60,19 @@ export default function SeatStatusReportPage() {
|
|||||||
.getBlocked()
|
.getBlocked()
|
||||||
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
|
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
|
||||||
});
|
});
|
||||||
const schedules = schedulesRaw ?? [];
|
|
||||||
|
|
||||||
const { data: bookingsData, isLoading } = useQuery({
|
const { data: bookingsData, isLoading } = useQuery({
|
||||||
queryKey: ["seat-report-bookings"],
|
queryKey: ["seat-report-bookings"],
|
||||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const bookedSeats = data?.bookedSeats ?? [];
|
const rows = useMemo<BookedSeatRow[]>(() => {
|
||||||
const blockedSeats = data?.blockedSeats ?? [];
|
const bookings: any[] = (bookingsData as any)?.data ?? (Array.isArray(bookingsData) ? bookingsData : []);
|
||||||
|
const result: BookedSeatRow[] = [];
|
||||||
for (const booking of bookings) {
|
for (const booking of bookings) {
|
||||||
if (booking.status === "CANCELLED") continue;
|
if (booking.status === "CANCELLED") continue;
|
||||||
const seats: any[] = booking.seats || [];
|
const seats: any[] = booking.seats || [];
|
||||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||||
|
|
||||||
for (const seat of seats) {
|
for (const seat of seats) {
|
||||||
result.push({
|
result.push({
|
||||||
bookingRef: booking.bookingRef || "—",
|
bookingRef: booking.bookingRef || "—",
|
||||||
@@ -111,15 +90,11 @@ export default function SeatStatusReportPage() {
|
|||||||
bookedAt: booking.createdAt,
|
bookedAt: booking.createdAt,
|
||||||
releaseAt: getReleaseAt(booking, seat),
|
releaseAt: getReleaseAt(booking, seat),
|
||||||
scheduleOrigin: booking.schedule?.originStation?.name || "—",
|
scheduleOrigin: booking.schedule?.originStation?.name || "—",
|
||||||
scheduleDestination:
|
scheduleDestination: booking.schedule?.destinationStation?.name || "—",
|
||||||
booking.schedule?.destinationStation?.name || "—",
|
|
||||||
scheduleDeparture: booking.schedule?.departureAt || "",
|
scheduleDeparture: booking.schedule?.departureAt || "",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [bookingsData]);
|
}, [bookingsData]);
|
||||||
|
|
||||||
@@ -134,8 +109,8 @@ export default function SeatStatusReportPage() {
|
|||||||
return (
|
return (
|
||||||
r.bookingRef.toLowerCase().includes(q) ||
|
r.bookingRef.toLowerCase().includes(q) ||
|
||||||
r.passengerName.toLowerCase().includes(q) ||
|
r.passengerName.toLowerCase().includes(q) ||
|
||||||
r.seatNumber.toLowerCase().includes(q) ||
|
(r.seatNumber ?? "").toLowerCase().includes(q) ||
|
||||||
r.coachNumber.toLowerCase().includes(q)
|
(r.coachNumber ?? "").toLowerCase().includes(q)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -154,18 +129,9 @@ export default function SeatStatusReportPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const headers = [
|
const headers = [
|
||||||
"Booking Ref",
|
"Booking Ref", "Passenger", "Seat", "Coach", "Fare",
|
||||||
"Passenger",
|
"Payment Status", "Booking Status", "Booked At", "Release At",
|
||||||
"Seat",
|
"Origin", "Destination", "Departure",
|
||||||
"Coach",
|
|
||||||
"Fare",
|
|
||||||
"Payment Status",
|
|
||||||
"Booking Status",
|
|
||||||
"Booked At",
|
|
||||||
"Release At",
|
|
||||||
"Origin",
|
|
||||||
"Destination",
|
|
||||||
"Departure",
|
|
||||||
];
|
];
|
||||||
const csvRows = filtered.map((r) => [
|
const csvRows = filtered.map((r) => [
|
||||||
r.bookingRef,
|
r.bookingRef,
|
||||||
@@ -197,12 +163,9 @@ export default function SeatStatusReportPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground">
|
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
|
||||||
Seat Status Report
|
|
||||||
</h1>
|
|
||||||
<p className="text-muted-foreground mt-1">
|
<p className="text-muted-foreground mt-1">
|
||||||
Track booked seats — paid vs unpaid, booking times, and hold release
|
Track booked seats — paid vs unpaid, booking times, and hold release times
|
||||||
times
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -211,34 +174,20 @@ export default function SeatStatusReportPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
|
||||||
Paid Seats
|
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">{paidCount}</p>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</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>
|
</div>
|
||||||
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
|
||||||
Unpaid Seats
|
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">{unpaidCount}</p>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</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>
|
</div>
|
||||||
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
@@ -247,46 +196,32 @@ export default function SeatStatusReportPage() {
|
|||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
|
||||||
Expired Holds
|
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">{expiredCount}</p>
|
||||||
</p>
|
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</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>
|
</div>
|
||||||
|
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-muted-foreground text-sm font-medium">
|
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||||
Blocked Seats
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||||
{blockedSeats.length}
|
{(blockedSeats as any[]).length}
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Manually blocked
|
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{blockedSeats.length > 0 && (
|
{(blockedSeats as any[]).length > 0 && (
|
||||||
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
|
<div className="mt-3 border-t border-border pt-3 flex flex-col gap-1 max-h-32 overflow-y-auto">
|
||||||
{blockedSeats.map((b: any) => (
|
{(blockedSeats as any[]).map((b: any) => (
|
||||||
<div
|
<div key={b.id} className="flex items-center justify-between text-xs">
|
||||||
key={b.id}
|
|
||||||
className="flex items-center justify-between text-xs"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
Seat {b.seatNumber} · Coach {b.coachNumber}
|
Seat {b.seatNumber} · Coach {b.coachNumber}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span className="text-muted-foreground truncate max-w-24" title={b.reason}>
|
||||||
className="text-muted-foreground truncate max-w-24"
|
|
||||||
title={b.reason}
|
|
||||||
>
|
|
||||||
{b.reason}
|
{b.reason}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,14 +279,8 @@ export default function SeatStatusReportPage() {
|
|||||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||||
<tr>
|
<tr>
|
||||||
{[
|
{[
|
||||||
"Booking Ref",
|
"Booking Ref", "Passenger", "Seat / Coach", "Fare",
|
||||||
"Passenger",
|
"Payment", "Booked At", "Release At", "Route",
|
||||||
"Seat / Coach",
|
|
||||||
"Fare",
|
|
||||||
"Payment",
|
|
||||||
"Booked At",
|
|
||||||
"Release At",
|
|
||||||
"Route",
|
|
||||||
].map((h) => (
|
].map((h) => (
|
||||||
<th
|
<th
|
||||||
key={h}
|
key={h}
|
||||||
@@ -383,8 +312,8 @@ export default function SeatStatusReportPage() {
|
|||||||
<span className="font-semibold">{row.seatNumber}</span>
|
<span className="font-semibold">{row.seatNumber}</span>
|
||||||
{row.coachNumber !== "—" && (
|
{row.coachNumber !== "—" && (
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{" "}
|
{" · Coach "}
|
||||||
· Coach {row.coachNumber}
|
{row.coachNumber}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
@@ -437,7 +366,7 @@ export default function SeatStatusReportPage() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ import {
|
|||||||
|
|
||||||
// ── 12-hour datetime picker ──────────────────────────────────────────────────
|
// ── 12-hour datetime picker ──────────────────────────────────────────────────
|
||||||
interface DTPProps {
|
interface DTPProps {
|
||||||
label: string;
|
label?: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
required?: boolean;
|
required?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** value / onChange use "YYYY-MM-DDTHH:mm" (24-hr, local) — same as datetime-local */
|
/** value / onChange use "YYYY-MM-DDTHH:mm" (24-hr, local) — same as datetime-local */
|
||||||
@@ -106,7 +107,6 @@ import DataTable from "@/components/ui/DataTable";
|
|||||||
import ActionButton from "@/components/ui/ActionButton";
|
import ActionButton from "@/components/ui/ActionButton";
|
||||||
import Modal from "@/components/ui/Modal";
|
import Modal from "@/components/ui/Modal";
|
||||||
import ConfirmDialog from "@/components/ui/ConfirmDialog";
|
import ConfirmDialog from "@/components/ui/ConfirmDialog";
|
||||||
import DateTimePicker from "@/components/ui/DateTimePicker";
|
|
||||||
import { apiClient } from "@/lib/api-client";
|
import { apiClient } from "@/lib/api-client";
|
||||||
import { routeCoachTemplatesApi } from "@/lib/api";
|
import { routeCoachTemplatesApi } from "@/lib/api";
|
||||||
import { formatDateTime } from "@/lib/utils";
|
import { formatDateTime } from "@/lib/utils";
|
||||||
|
|||||||
@@ -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', 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: '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 },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { DayPicker } from 'react-day-picker';
|
||||||
|
import { ChevronLeft, ChevronRight, Calendar, X } from 'lucide-react';
|
||||||
|
import { format, parse, isValid } from 'date-fns';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface DatePickerProps {
|
||||||
|
value: string; // YYYY-MM-DD
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
minDate?: Date;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DatePicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder = 'Pick a date',
|
||||||
|
disabled = false,
|
||||||
|
minDate,
|
||||||
|
className,
|
||||||
|
}: DatePickerProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const [popoverPos, setPopoverPos] = useState({ top: 0, left: 0 });
|
||||||
|
|
||||||
|
useEffect(() => { setMounted(true); }, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') setOpen(false); };
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const selected = value
|
||||||
|
? parse(value, 'yyyy-MM-dd', new Date())
|
||||||
|
: undefined;
|
||||||
|
const validSelected = selected && isValid(selected) ? selected : undefined;
|
||||||
|
|
||||||
|
// Clear the value if it's now before the minDate
|
||||||
|
useEffect(() => {
|
||||||
|
if (minDate && validSelected && validSelected < minDate) {
|
||||||
|
onChange('');
|
||||||
|
}
|
||||||
|
}, [minDate, validSelected, onChange]);
|
||||||
|
|
||||||
|
function handleOpen() {
|
||||||
|
if (disabled) return;
|
||||||
|
if (triggerRef.current) {
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setPopoverPos({
|
||||||
|
top: rect.bottom + window.scrollY + 6,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(date: Date | undefined) {
|
||||||
|
if (date && isValid(date)) {
|
||||||
|
onChange(format(date, 'yyyy-MM-dd'));
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClear(e: React.MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
onChange('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayText = validSelected
|
||||||
|
? format(validSelected, 'MMM d, yyyy')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const popover = open && mounted ? createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0"
|
||||||
|
style={{ zIndex: 9999 }}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="absolute bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-xl shadow-xl p-3"
|
||||||
|
style={{ zIndex: 10000, top: popoverPos.top, left: popoverPos.left }}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<DayPicker
|
||||||
|
mode="single"
|
||||||
|
selected={validSelected}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
disabled={minDate ? { before: minDate } : undefined}
|
||||||
|
showOutsideDays
|
||||||
|
classNames={{
|
||||||
|
root: 'w-full',
|
||||||
|
months: 'w-full',
|
||||||
|
month: 'w-full',
|
||||||
|
month_caption: 'flex items-center justify-between mb-3',
|
||||||
|
caption_label: 'text-sm font-semibold text-gray-900 dark:text-white',
|
||||||
|
nav: 'flex items-center gap-1',
|
||||||
|
button_previous: 'h-7 w-7 rounded-lg flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors',
|
||||||
|
button_next: 'h-7 w-7 rounded-lg flex items-center justify-center text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors',
|
||||||
|
month_grid: 'w-full border-collapse',
|
||||||
|
weekdays: 'flex w-full mb-1',
|
||||||
|
weekday: 'flex-1 text-center text-xs font-medium text-gray-400 dark:text-gray-500 py-1',
|
||||||
|
weeks: '',
|
||||||
|
week: 'flex w-full mt-0.5',
|
||||||
|
day: 'flex-1 flex items-center justify-center p-0',
|
||||||
|
day_button: 'h-8 w-8 text-xs rounded-lg flex items-center justify-center transition-colors hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-gray-700 dark:text-gray-300',
|
||||||
|
selected: '',
|
||||||
|
today: '',
|
||||||
|
outside: 'opacity-30',
|
||||||
|
disabled: 'opacity-20 cursor-not-allowed',
|
||||||
|
hidden: 'invisible',
|
||||||
|
range_start: '',
|
||||||
|
range_end: '',
|
||||||
|
range_middle: '',
|
||||||
|
focused: 'ring-1 ring-blue-500/50',
|
||||||
|
chevron: '',
|
||||||
|
dropdowns: '',
|
||||||
|
dropdown: '',
|
||||||
|
dropdown_root: '',
|
||||||
|
footer: '',
|
||||||
|
months_dropdown: '',
|
||||||
|
week_number: '',
|
||||||
|
week_number_header: '',
|
||||||
|
years_dropdown: '',
|
||||||
|
weeks_after_enter: '',
|
||||||
|
weeks_after_exit: '',
|
||||||
|
weeks_before_enter: '',
|
||||||
|
weeks_before_exit: '',
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Chevron: ({ orientation }) =>
|
||||||
|
orientation === 'left'
|
||||||
|
? <ChevronLeft className="h-4 w-4" />
|
||||||
|
: <ChevronRight className="h-4 w-4" />,
|
||||||
|
DayButton: ({ day, modifiers, className: cls, ...props }) => (
|
||||||
|
<button
|
||||||
|
{...props}
|
||||||
|
className={cn(
|
||||||
|
cls,
|
||||||
|
modifiers.selected && 'bg-blue-600 text-white font-semibold hover:bg-blue-700',
|
||||||
|
modifiers.today && !modifiers.selected && 'text-blue-600 dark:text-blue-400 font-bold',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('relative', className)}>
|
||||||
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
|
type="button"
|
||||||
|
onClick={handleOpen}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-3 py-2 rounded-lg border text-sm transition-colors',
|
||||||
|
'border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800',
|
||||||
|
'text-gray-900 dark:text-white',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-blue-500',
|
||||||
|
'disabled:opacity-40 disabled:cursor-not-allowed',
|
||||||
|
!displayText && 'text-gray-400 dark:text-gray-500',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4 shrink-0 text-gray-400 dark:text-gray-500" />
|
||||||
|
<span className="min-w-[90px] text-left">{displayText ?? placeholder}</span>
|
||||||
|
{displayText && !disabled && (
|
||||||
|
<X
|
||||||
|
className="w-3.5 h-3.5 shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 ml-1"
|
||||||
|
onClick={handleClear}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{popover}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user