Added payment discrepancy report

This commit is contained in:
Roba Boru
2026-07-20 15:50:03 +03:00
parent 5e9feab869
commit 1e7324d276
9 changed files with 892 additions and 124 deletions

View File

@@ -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[];

View File

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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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";

View File

@@ -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 },
]
},

View File

@@ -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>
);
}