mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Expand to show multiple passengers in payment discrepancy
This commit is contained in:
@@ -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 },
|
||||
|
||||
@@ -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,68 +330,133 @@ export default function PaymentDiscrepancyPage() {
|
||||
</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>
|
||||
{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"
|
||||
>
|
||||
<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>
|
||||
{/* 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
|
||||
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>
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user