mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Added payment discrepancy report
This commit is contained in:
@@ -56,9 +56,11 @@ interface PassengerRow {
|
||||
seatClassName: string | null;
|
||||
coachNumber: string | null;
|
||||
coachType: string | null;
|
||||
coachSeat: string | null;
|
||||
nationality: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
departureAt: string | null;
|
||||
amountPaidMinor: number;
|
||||
currency: string;
|
||||
isGroupBooking: boolean;
|
||||
@@ -73,6 +75,7 @@ export default function PassengersReportPage() {
|
||||
const [listSearch, setListSearch] = useState("");
|
||||
const [filterCoach, setFilterCoach] = useState("");
|
||||
const [filterOrigin, setFilterOrigin] = useState("");
|
||||
const [filterSeatClass, setFilterSeatClass] = useState("");
|
||||
|
||||
const { data: schedulesRaw, isLoading: loadingSchedules } = useQuery<
|
||||
ScheduleOption[]
|
||||
@@ -101,6 +104,9 @@ export default function PassengersReportPage() {
|
||||
const coachOptions = [
|
||||
...new Set(passengerList.map((p) => p.coachNumber).filter(Boolean)),
|
||||
].sort() as string[];
|
||||
const seatClassOptions = [
|
||||
...new Set(passengerList.map((p) => p.seatClassName).filter(Boolean)),
|
||||
].sort() as string[];
|
||||
const originOptions = [
|
||||
...new Set(passengerList.map((p) => p.origin).filter(Boolean)),
|
||||
].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 {
|
||||
Download,
|
||||
Armchair,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Ban,
|
||||
} from "lucide-react";
|
||||
import { bookingsApi, seatsApi } from "@/lib/api";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
import ActionButton from "@/components/ui/ActionButton";
|
||||
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 {
|
||||
bookingRef: string;
|
||||
passengerName: string;
|
||||
passengerCategory: string;
|
||||
coachNumber: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClassName: string | null;
|
||||
coachNumber: string;
|
||||
seatNumber: string;
|
||||
fareMinor: number;
|
||||
currency: string;
|
||||
bookingStatus: string;
|
||||
paymentStatus: string;
|
||||
bookedAt: string;
|
||||
}
|
||||
|
||||
interface BlockedSeatRow {
|
||||
id: string;
|
||||
coachNumber: string | null;
|
||||
seatNumber: string | null;
|
||||
seatClassName: string | null;
|
||||
reason: string;
|
||||
blockedBy: string;
|
||||
blockedAt: string;
|
||||
unblockAt: string | null;
|
||||
releaseAt: string | null;
|
||||
scheduleOrigin: string;
|
||||
scheduleDestination: string;
|
||||
scheduleDeparture: string;
|
||||
}
|
||||
|
||||
function getReleaseAt(booking: any, seat: any): string | null {
|
||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED")
|
||||
return null;
|
||||
if (paymentStatus === "SUCCEEDED" || paymentStatus === "COMPLETED") return null;
|
||||
if (booking.status === "CONFIRMED") return null;
|
||||
if (seat?.holdExpiresAt) return seat.holdExpiresAt;
|
||||
if (booking.createdAt) {
|
||||
@@ -56,20 +49,8 @@ function getReleaseAt(booking: any, seat: any): string | 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() {
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">(
|
||||
"ALL",
|
||||
);
|
||||
const [statusFilter, setStatusFilter] = useState<"ALL" | "PAID" | "UNPAID">("ALL");
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const { data: blockedSeats = [] } = useQuery({
|
||||
@@ -79,21 +60,19 @@ export default function SeatStatusReportPage() {
|
||||
.getBlocked()
|
||||
.then((r: any) => (Array.isArray(r) ? r : (r?.data ?? []))),
|
||||
});
|
||||
const schedules = schedulesRaw ?? [];
|
||||
|
||||
const { data: bookingsData, isLoading } = useQuery({
|
||||
queryKey: ["seat-report-bookings"],
|
||||
queryFn: () => bookingsApi.getAll({ pageSize: 1000 }),
|
||||
});
|
||||
|
||||
const bookedSeats = data?.bookedSeats ?? [];
|
||||
const blockedSeats = data?.blockedSeats ?? [];
|
||||
|
||||
const rows = useMemo<BookedSeatRow[]>(() => {
|
||||
const bookings: any[] = (bookingsData as any)?.data ?? (Array.isArray(bookingsData) ? bookingsData : []);
|
||||
const result: BookedSeatRow[] = [];
|
||||
for (const booking of bookings) {
|
||||
if (booking.status === "CANCELLED") continue;
|
||||
const seats: any[] = booking.seats || [];
|
||||
const paymentStatus = booking.paymentIntent?.status || "PENDING";
|
||||
|
||||
for (const seat of seats) {
|
||||
result.push({
|
||||
bookingRef: booking.bookingRef || "—",
|
||||
@@ -111,15 +90,11 @@ export default function SeatStatusReportPage() {
|
||||
bookedAt: booking.createdAt,
|
||||
releaseAt: getReleaseAt(booking, seat),
|
||||
scheduleOrigin: booking.schedule?.originStation?.name || "—",
|
||||
scheduleDestination:
|
||||
booking.schedule?.destinationStation?.name || "—",
|
||||
scheduleDestination: booking.schedule?.destinationStation?.name || "—",
|
||||
scheduleDeparture: booking.schedule?.departureAt || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [bookingsData]);
|
||||
|
||||
@@ -134,8 +109,8 @@ export default function SeatStatusReportPage() {
|
||||
return (
|
||||
r.bookingRef.toLowerCase().includes(q) ||
|
||||
r.passengerName.toLowerCase().includes(q) ||
|
||||
r.seatNumber.toLowerCase().includes(q) ||
|
||||
r.coachNumber.toLowerCase().includes(q)
|
||||
(r.seatNumber ?? "").toLowerCase().includes(q) ||
|
||||
(r.coachNumber ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
@@ -154,18 +129,9 @@ export default function SeatStatusReportPage() {
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
"Booking Ref",
|
||||
"Passenger",
|
||||
"Seat",
|
||||
"Coach",
|
||||
"Fare",
|
||||
"Payment Status",
|
||||
"Booking Status",
|
||||
"Booked At",
|
||||
"Release At",
|
||||
"Origin",
|
||||
"Destination",
|
||||
"Departure",
|
||||
"Booking Ref", "Passenger", "Seat", "Coach", "Fare",
|
||||
"Payment Status", "Booking Status", "Booked At", "Release At",
|
||||
"Origin", "Destination", "Departure",
|
||||
];
|
||||
const csvRows = filtered.map((r) => [
|
||||
r.bookingRef,
|
||||
@@ -197,12 +163,9 @@ export default function SeatStatusReportPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">
|
||||
Seat Status Report
|
||||
</h1>
|
||||
<h1 className="text-3xl font-bold text-foreground">Seat Status Report</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Track booked seats — paid vs unpaid, booking times, and hold release
|
||||
times
|
||||
Track booked seats — paid vs unpaid, booking times, and hold release times
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -211,34 +174,20 @@ export default function SeatStatusReportPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Paid Seats
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">
|
||||
{paidCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Payment confirmed
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">Paid Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-green-600 dark:text-green-400">{paidCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Payment confirmed</p>
|
||||
</div>
|
||||
<CheckCircle className="h-8 w-8 text-green-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
{isLoading && <p className="text-xs text-muted-foreground mt-2">Loading…</p>}
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Unpaid Seats
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">
|
||||
{unpaidCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Awaiting payment
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">Unpaid Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-amber-600 dark:text-amber-400">{unpaidCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Awaiting payment</p>
|
||||
</div>
|
||||
<Clock className="h-8 w-8 text-amber-500 opacity-30" />
|
||||
</div>
|
||||
@@ -247,46 +196,32 @@ export default function SeatStatusReportPage() {
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Expired Holds
|
||||
</p>
|
||||
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">
|
||||
{expiredCount}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Hold time passed, not paid
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">Expired Holds</p>
|
||||
<p className="text-2xl font-bold mt-2 text-red-600 dark:text-red-400">{expiredCount}</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Hold time passed, not paid</p>
|
||||
</div>
|
||||
<AlertCircle className="h-8 w-8 text-red-500 opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-muted-foreground text-sm font-medium">
|
||||
Blocked Seats
|
||||
</p>
|
||||
<p className="text-muted-foreground text-sm font-medium">Blocked Seats</p>
|
||||
<p className="text-2xl font-bold mt-2 text-slate-600 dark:text-slate-400">
|
||||
{blockedSeats.length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Manually blocked
|
||||
{(blockedSeats as any[]).length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Manually blocked</p>
|
||||
</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">
|
||||
{blockedSeats.map((b: any) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-center justify-between text-xs"
|
||||
>
|
||||
{(blockedSeats as any[]).map((b: any) => (
|
||||
<div key={b.id} className="flex items-center justify-between text-xs">
|
||||
<span className="font-medium text-foreground">
|
||||
Seat {b.seatNumber} · Coach {b.coachNumber}
|
||||
</span>
|
||||
<span
|
||||
className="text-muted-foreground truncate max-w-24"
|
||||
title={b.reason}
|
||||
>
|
||||
<span className="text-muted-foreground truncate max-w-24" title={b.reason}>
|
||||
{b.reason}
|
||||
</span>
|
||||
</div>
|
||||
@@ -344,14 +279,8 @@ export default function SeatStatusReportPage() {
|
||||
<thead className="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
{[
|
||||
"Booking Ref",
|
||||
"Passenger",
|
||||
"Seat / Coach",
|
||||
"Fare",
|
||||
"Payment",
|
||||
"Booked At",
|
||||
"Release At",
|
||||
"Route",
|
||||
"Booking Ref", "Passenger", "Seat / Coach", "Fare",
|
||||
"Payment", "Booked At", "Release At", "Route",
|
||||
].map((h) => (
|
||||
<th
|
||||
key={h}
|
||||
@@ -383,8 +312,8 @@ export default function SeatStatusReportPage() {
|
||||
<span className="font-semibold">{row.seatNumber}</span>
|
||||
{row.coachNumber !== "—" && (
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· Coach {row.coachNumber}
|
||||
{" · Coach "}
|
||||
{row.coachNumber}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
@@ -437,7 +366,7 @@ export default function SeatStatusReportPage() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ import {
|
||||
|
||||
// ── 12-hour datetime picker ──────────────────────────────────────────────────
|
||||
interface DTPProps {
|
||||
label: string;
|
||||
label?: string;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/** 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 Modal from "@/components/ui/Modal";
|
||||
import ConfirmDialog from "@/components/ui/ConfirmDialog";
|
||||
import DateTimePicker from "@/components/ui/DateTimePicker";
|
||||
import { apiClient } from "@/lib/api-client";
|
||||
import { routeCoachTemplatesApi } from "@/lib/api";
|
||||
import { formatDateTime } from "@/lib/utils";
|
||||
|
||||
@@ -121,9 +121,10 @@ const navigationSections: { title: string; items: NavItem[] }[] = [
|
||||
{
|
||||
title: 'Analytics & Reports',
|
||||
items: [
|
||||
{ name: 'Overall', href: '/reports', icon: BarChart3, 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: 'Overall', href: '/reports', icon: BarChart3, 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: 'Payment Discrepancy', href: '/reports/payment-discrepancy', icon: AlertTriangle, 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