From 1e7324d27694f07372ee8d32a52e3c5a1a2b99fb Mon Sep 17 00:00:00 2001 From: Roba Boru Date: Mon, 20 Jul 2026 15:50:03 +0300 Subject: [PATCH] Added payment discrepancy report --- .../src/modules/reports/reports.controller.ts | 11 + .../src/modules/reports/reports.service.ts | 220 ++++++++++ .../src/app/reports/passengers/page.tsx | 6 + .../reports/payment-discrepancy/layout.tsx | 3 + .../app/reports/payment-discrepancy/page.tsx | 410 ++++++++++++++++++ .../backoffice/src/app/reports/seats/page.tsx | 167 ++----- .../backoffice/src/app/schedules/page.tsx | 4 +- .../src/components/layout/Sidebar.tsx | 7 +- .../src/components/ui/DatePicker.tsx | 188 ++++++++ 9 files changed, 892 insertions(+), 124 deletions(-) create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/layout.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx create mode 100644 apps/edr-passenger-web/backoffice/src/components/ui/DatePicker.tsx diff --git a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts index 7d832b8c9..8dceea63a 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.controller.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.controller.ts @@ -36,6 +36,17 @@ export class ReportsController { 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") @ApiOperation({ summary: "Get report by ID" }) getReport(@Param("reportId") reportId: string) { diff --git a/apps/edr-passenger-api/src/modules/reports/reports.service.ts b/apps/edr-passenger-api/src/modules/reports/reports.service.ts index 5730fb7ee..d44100fc8 100644 --- a/apps/edr-passenger-api/src/modules/reports/reports.service.ts +++ b/apps/edr-passenger-api/src/modules/reports/reports.service.ts @@ -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(); + 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 = {}; + 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) { return this.prisma.operationalReport.findUnique({ where: { id: reportId }, diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx index 558526b30..93470daeb 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/passengers/page.tsx @@ -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[]; diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/layout.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/layout.tsx new file mode 100644 index 000000000..bcbaad6c8 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/layout.tsx @@ -0,0 +1,3 @@ +export default function PaymentDiscrepancyLayout({ children }: { children: React.ReactNode }) { + return <>{children}; +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx new file mode 100644 index 000000000..168003887 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/app/reports/payment-discrepancy/page.tsx @@ -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 ( + + + Fully paid + + ); + } + return ( + + + {fmtMoney(row.balanceMinor, row.balanceCurrency)} + + ); +} + +// ── 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(null); + + const isSearchMode = !!(applied?.search); + + const { data, isLoading, isError, refetch } = useQuery({ + 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 ( +
+ + {/* Page header */} +
+
+ +
+
+

Payment Discrepancy Report

+

+ Bookings where the amount paid is less than the actual fare — flagged for follow-up +

+
+
+ + {/* Filters — single row */} +
+
+ + {/* PNR / Ticket search — grows to fill available space */} +
+ + 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 && ( + + )} +
+ + or + + {/* Date range */} + + + + + {/* Sort */} + + + {/* Actions */} +
+ {applied && ( + + )} + {rows.length > 0 && !isSearchMode && ( + + )} + +
+
+
+ + {/* Error */} + {isError && ( +
+ + Failed to load discrepancy data. Please try again. +
+ )} + + {/* Not found */} + {data?.notFound && ( +
+ + No booking found for {applied?.search} — check the PNR or ticket number and try again. +
+ )} + + {/* Summary — date-range mode, only when there are results */} + {data && !isSearchMode && !data.notFound && data.total > 0 && ( +
+
+ {data.total} + Underpaid bookings +
+ {data.totalBalanceEtbMinor > 0 && ( +
+ + {fmtMoney(data.totalBalanceEtbMinor, 'ETB')} + + Total outstanding (ETB) +
+ )} +
+ )} + + {/* Table */} + {rows.length > 0 && ( +
+ {isSearchMode && ( +
+ + Lookup result for + + + {applied?.search} + + +
+ )} +
+ + + + {[ + 'Passenger', 'PNR', 'Booking Date', 'Route', 'Departure', + 'Seat Type', 'Actual Price', 'Paid', 'Balance', 'Phone', + ].map(h => ( + + ))} + + + + {rows.map((row, i) => ( + + + + + + + + + + + + + ))} + +
+ {h} +
+ {row.passengerName} + + + {row.pnr} + + + {new Date(row.bookingDate).toLocaleDateString('en-GB', { day: '2-digit', month: 'short', year: 'numeric' })} + + + {row.origin.city || row.origin.name} + + + + {row.destination.city || row.destination.name} + + + {new Date(row.departureAt).toLocaleString('en-US', { + month: 'short', day: 'numeric', + hour: 'numeric', minute: '2-digit', hour12: true, + timeZone: 'Africa/Addis_Ababa', + })} + +
{row.seatType}
+ {row.coachNumber && ( +
Coach {row.coachNumber}
+ )} +
+ {fmtMoney(row.actualMinor, row.actualCurrency)} + + {fmtMoney(row.paidMinor, row.paidCurrency)} + + + + {row.phone && row.phone !== '—' ? ( + + + {row.phone} + + ) : ( + + )} +
+
+ {!isSearchMode && ( +
+ {rows.length} record{rows.length !== 1 ? 's' : ''} — click a phone number to call directly, or export CSV for bulk follow-up +
+ )} +
+ )} + + {/* Empty state — date range returned nothing */} + {applied && !isSearchMode && !isLoading && !isError && rows.length === 0 && data && !data.notFound && ( +
+ +

No underpaid bookings found for this period

+
+ )} + + {/* Initial state */} + {!applied && !isLoading && ( +
+ +

Enter a PNR or ticket number above, or select a date range to generate the report

+
+ )} +
+ ); +} diff --git a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx index a31aab988..92e18b919 100644 --- a/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/reports/seats/page.tsx @@ -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(() => { + 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 (
-

- Seat Status Report -

+

Seat Status Report

- Track booked seats — paid vs unpaid, booking times, and hold release - times + Track booked seats — paid vs unpaid, booking times, and hold release times

@@ -211,34 +174,20 @@ export default function SeatStatusReportPage() {
-

- Paid Seats -

-

- {paidCount} -

-

- Payment confirmed -

+

Paid Seats

+

{paidCount}

+

Payment confirmed

- {isLoading &&

Loading…

} -
-

- Unpaid Seats -

-

- {unpaidCount} -

-

- Awaiting payment -

+

Unpaid Seats

+

{unpaidCount}

+

Awaiting payment

@@ -247,46 +196,32 @@ export default function SeatStatusReportPage() {
-

- Expired Holds -

-

- {expiredCount} -

-

- Hold time passed, not paid -

+

Expired Holds

+

{expiredCount}

+

Hold time passed, not paid

+
+
-

- Blocked Seats -

+

Blocked Seats

- {blockedSeats.length} -

-

- Manually blocked + {(blockedSeats as any[]).length}

+

Manually blocked

- {blockedSeats.length > 0 && ( + {(blockedSeats as any[]).length > 0 && (
- {blockedSeats.map((b: any) => ( -
+ {(blockedSeats as any[]).map((b: any) => ( +
Seat {b.seatNumber} · Coach {b.coachNumber} - + {b.reason}
@@ -344,14 +279,8 @@ export default function SeatStatusReportPage() { {[ - "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) => ( {row.seatNumber} {row.coachNumber !== "—" && ( - {" "} - · Coach {row.coachNumber} + {" · Coach "} + {row.coachNumber} )} @@ -437,7 +366,7 @@ export default function SeatStatusReportPage() {
- )} +
); } diff --git a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx index 9a990096b..298e6cfdc 100644 --- a/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx +++ b/apps/edr-passenger-web/backoffice/src/app/schedules/page.tsx @@ -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"; diff --git a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx index 9d5c62414..14ff2f161 100644 --- a/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx +++ b/apps/edr-passenger-web/backoffice/src/components/layout/Sidebar.tsx @@ -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 }, ] }, diff --git a/apps/edr-passenger-web/backoffice/src/components/ui/DatePicker.tsx b/apps/edr-passenger-web/backoffice/src/components/ui/DatePicker.tsx new file mode 100644 index 000000000..e8f699432 --- /dev/null +++ b/apps/edr-passenger-web/backoffice/src/components/ui/DatePicker.tsx @@ -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(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( + <> +
setOpen(false)} + /> +
e.stopPropagation()} + > + + orientation === 'left' + ? + : , + DayButton: ({ day, modifiers, className: cls, ...props }) => ( +
+ , + document.body, + ) : null; + + return ( +
+ + {popover} +
+ ); +}