Expand to show multiple passengers in payment discrepancy

This commit is contained in:
Roba Boru
2026-07-20 16:10:28 +03:00
parent 6ea2216aeb
commit ef2fb2e05c
2 changed files with 191 additions and 81 deletions

View File

@@ -629,6 +629,28 @@ export class ReportsService {
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 seatSelect = {
where: { leg: 1 },
orderBy: [
{ seat: { coach: { number: 'asc' as const } } },
{ seat: { seatNumber: 'asc' as const } },
],
select: {
passengerName: true,
passengerCategory: true,
seatLabelSnapshot: true,
fareMinor: true,
displayFareMinor: true,
displayCurrency: true,
seat: {
select: {
seatNumber: true,
coach: { select: { number: true, coachType: { select: { name: true } } } },
},
},
},
};
const bookings = await this.prisma.booking.findMany({
where: {
status: { in: ['CONFIRMED', 'BOARDED', 'NO_SHOW'] as any },
@@ -643,21 +665,7 @@ export class ReportsService {
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 } } } },
},
},
},
},
seats: seatSelect,
passenger: {
select: { user: { select: { phone: true, fullName: true } } },
},
@@ -684,6 +692,7 @@ export class ReportsService {
const balanceCurrency = 'ETB';
const firstSeat = b.seats[0];
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
return {
pnr: b.bookingRef,
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
@@ -700,6 +709,8 @@ export class ReportsService {
paidCurrency,
balanceMinor,
balanceCurrency,
passengerCount: passengers.length,
passengers,
};
})
.filter(r => r.balanceMinor > 0);
@@ -749,15 +760,21 @@ export class ReportsService {
},
},
seats: {
take: 1,
orderBy: { leg: 'asc' },
where: { leg: 1 },
orderBy: [
{ seat: { coach: { number: 'asc' } } },
{ seat: { seatNumber: 'asc' } },
],
select: {
passengerName: true,
passengerCategory: true,
seatLabelSnapshot: true,
fareMinor: true,
displayFareMinor: true,
displayCurrency: true,
seat: {
select: {
seatNumber: true,
bedPosition: true,
coach: { select: { number: true, coachType: { select: { name: true } } } },
},
},
@@ -783,6 +800,7 @@ export class ReportsService {
const balanceCurrency = 'ETB';
const firstSeat = b.seats[0];
const passengers = this.buildSeatPassengers(b.seats, actualCurrency);
const row = {
pnr: b.bookingRef,
passengerName: firstSeat?.passengerName ?? b.passenger?.user?.fullName ?? '—',
@@ -801,6 +819,8 @@ export class ReportsService {
balanceCurrency,
bookingStatus: b.status,
paymentStatus: pi?.status ?? null,
passengerCount: passengers.length,
passengers,
};
return {
@@ -811,6 +831,18 @@ export class ReportsService {
};
}
private buildSeatPassengers(seats: any[], fallbackCurrency: string) {
return seats.map(s => ({
name: s.passengerName as string,
category: s.passengerCategory as string,
seatNumber: (s.seat?.seatNumber ?? null) as string | null,
coachNumber: (s.seat?.coach?.number ?? null) as string | null,
seatType: (s.seatLabelSnapshot ?? s.seat?.coach?.coachType?.name ?? null) as string | null,
fareMinor: (s.displayFareMinor ?? s.fareMinor ?? null) as number | null,
fareCurrency: ((s.displayCurrency as string | null) ?? fallbackCurrency),
}));
}
async getReport(reportId: string) {
return this.prisma.operationalReport.findUnique({
where: { id: reportId },

View File

@@ -4,7 +4,7 @@ import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
AlertTriangle, CheckCircle2, Download, Search,
Loader2, PhoneCall, RefreshCw, X,
Loader2, PhoneCall, RefreshCw, X, Users, ChevronDown, ChevronUp,
} from 'lucide-react';
import { apiClient } from '@/lib/api-client';
import DatePicker from '@/components/ui/DatePicker';
@@ -18,6 +18,16 @@ interface Station {
city: string;
}
interface PassengerDetail {
name: string;
category: string;
seatNumber: string | null;
coachNumber: string | null;
seatType: string | null;
fareMinor: number | null;
fareCurrency: string;
}
interface DiscrepancyRow {
pnr: string;
passengerName: string;
@@ -34,6 +44,8 @@ interface DiscrepancyRow {
paidCurrency: string;
balanceMinor: number;
balanceCurrency: string;
passengerCount: number;
passengers: PassengerDetail[];
bookingStatus?: string;
paymentStatus?: string | null;
}
@@ -109,6 +121,7 @@ export default function PaymentDiscrepancyPage() {
const [sortBy, setSortBy] = useState<'balance' | 'departure'>('balance');
const [search, setSearch] = useState('');
const [applied, setApplied] = useState<Applied | null>(null);
const [expandedPnr, setExpandedPnr] = useState<string | null>(null);
const isSearchMode = !!(applied?.search);
@@ -305,7 +318,7 @@ export default function PaymentDiscrepancyPage() {
<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',
'Seat Type', 'Actual Price', 'Paid', 'Balance', 'Passengers', 'Phone',
].map(h => (
<th
key={h}
@@ -317,7 +330,11 @@ export default function PaymentDiscrepancyPage() {
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
{rows.map((row, i) => (
{rows.map((row, i) => {
const isExpanded = expandedPnr === row.pnr;
const hasMultiple = row.passengerCount > 1;
return (
<>
<tr
key={row.pnr + i}
className="hover:bg-gray-50 dark:hover:bg-gray-800/40 transition-colors"
@@ -364,6 +381,23 @@ export default function PaymentDiscrepancyPage() {
<td className="px-4 py-3 whitespace-nowrap">
<BalanceBadge row={row} />
</td>
{/* Passengers column */}
<td className="px-4 py-3 whitespace-nowrap">
{hasMultiple ? (
<button
onClick={() => setExpandedPnr(isExpanded ? null : row.pnr)}
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium bg-blue-50 dark:bg-blue-950/30 text-blue-700 dark:text-blue-300 border border-blue-200 dark:border-blue-800 hover:bg-blue-100 dark:hover:bg-blue-950/50 transition-colors"
>
<Users className="w-3 h-3" />
{row.passengerCount}
{isExpanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
) : (
<span className="text-xs text-gray-400 dark:text-gray-500 flex items-center gap-1">
<Users className="w-3 h-3" />1
</span>
)}
</td>
<td className="px-4 py-3 whitespace-nowrap">
{row.phone && row.phone !== '—' ? (
<a
@@ -378,9 +412,53 @@ export default function PaymentDiscrepancyPage() {
)}
</td>
</tr>
{/* Expandable passenger breakdown */}
{isExpanded && (
<tr key={row.pnr + '-passengers'} className="bg-blue-50/50 dark:bg-blue-950/10">
<td colSpan={11} className="px-6 py-3">
<div className="text-xs font-semibold text-blue-700 dark:text-blue-400 uppercase tracking-wide mb-2 flex items-center gap-1.5">
<Users className="w-3.5 h-3.5" />
Passengers under {row.pnr}
</div>
<table className="w-full text-xs">
<thead>
<tr className="text-left text-gray-500 dark:text-gray-400">
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">#</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Name</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Category</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Coach</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Seat</th>
<th className="pb-1.5 pr-4 font-semibold uppercase tracking-wide">Class</th>
<th className="pb-1.5 font-semibold uppercase tracking-wide">Fare</th>
</tr>
</thead>
<tbody className="divide-y divide-blue-100 dark:divide-blue-900/30">
{row.passengers.map((p, pi) => (
<tr key={pi} className="text-gray-700 dark:text-gray-300">
<td className="py-1.5 pr-4 text-gray-400">{pi + 1}</td>
<td className="py-1.5 pr-4 font-medium text-gray-900 dark:text-white whitespace-nowrap">{p.name}</td>
<td className="py-1.5 pr-4 capitalize">{p.category.toLowerCase()}</td>
<td className="py-1.5 pr-4">{p.coachNumber ?? '—'}</td>
<td className="py-1.5 pr-4 font-mono">{p.seatNumber ?? '—'}</td>
<td className="py-1.5 pr-4">{p.seatType ?? '—'}</td>
<td className="py-1.5 font-medium">
{p.fareMinor != null
? fmtMoney(p.fareMinor, p.fareCurrency)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</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">