mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
Payment discrepancy report updates
This commit is contained in:
@@ -418,6 +418,7 @@ export class ReportsService {
|
|||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
departureAt: true,
|
departureAt: true,
|
||||||
|
isPackageOnly: true,
|
||||||
train: { select: { number: true } },
|
train: { select: { number: true } },
|
||||||
originStation: { select: { name: true } },
|
originStation: { select: { name: true } },
|
||||||
destinationStation: { select: { name: true } },
|
destinationStation: { select: { name: true } },
|
||||||
@@ -428,7 +429,10 @@ export class ReportsService {
|
|||||||
return schedules.map((s) => ({
|
return schedules.map((s) => ({
|
||||||
id: s.id,
|
id: s.id,
|
||||||
departureAt: s.departureAt,
|
departureAt: s.departureAt,
|
||||||
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}`,
|
isPackage: s.isPackageOnly,
|
||||||
|
label: `${s.train.number} · ${s.originStation.name} → ${s.destinationStation.name} · ${new Date(s.departureAt).toLocaleString('en-GB', { dateStyle: 'medium', timeStyle: 'short' })}${
|
||||||
|
s.isPackageOnly ? ' (package)' : ''
|
||||||
|
}`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,6 +919,7 @@ export class ReportsService {
|
|||||||
destinationStation: { select: { name: true } },
|
destinationStation: { select: { name: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
package: { select: { id: true } },
|
||||||
seats: {
|
seats: {
|
||||||
where: { leg: 1 },
|
where: { leg: 1 },
|
||||||
orderBy: [
|
orderBy: [
|
||||||
@@ -953,29 +958,34 @@ export class ReportsService {
|
|||||||
const paidMinor = Math.round(pi.amountMinor);
|
const paidMinor = Math.round(pi.amountMinor);
|
||||||
const varianceMinor = actualMinor - paidMinor;
|
const varianceMinor = actualMinor - paidMinor;
|
||||||
|
|
||||||
|
const isPackage = !!(b as any).package;
|
||||||
|
const effectiveActualMinor = isPackage ? actualMinor * 2 : actualMinor;
|
||||||
|
const effectiveVarianceMinor = effectiveActualMinor - paidMinor;
|
||||||
|
|
||||||
const breakdown = b.seats.map(s => ({
|
const breakdown = b.seats.map(s => ({
|
||||||
passengerName: s.passengerName ?? '—',
|
passengerName: s.passengerName ?? '—',
|
||||||
seatClass: resolveSeatClass(s.seat),
|
seatClass: resolveSeatClass(s.seat),
|
||||||
coachNumber: s.seat?.coach?.number ?? null,
|
coachNumber: s.seat?.coach?.number ?? null,
|
||||||
seatNumber: s.seat?.seatNumber ?? null,
|
seatNumber: s.seat?.seatNumber ?? null,
|
||||||
fareMinor: s.fareMinor ?? 0,
|
fareMinor: isPackage ? (s.fareMinor ?? 0) * 2 : (s.fareMinor ?? 0),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const firstSeat = b.seats[0];
|
const firstSeat = b.seats[0];
|
||||||
return {
|
return {
|
||||||
bookingRef: b.bookingRef,
|
bookingRef: b.bookingRef,
|
||||||
|
isPackage,
|
||||||
seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
seatClass: firstSeat?.seatLabelSnapshot ?? firstSeat?.seat?.coach?.coachType?.name ?? '—',
|
||||||
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
coachNumber: firstSeat?.seat?.coach?.number ?? null,
|
||||||
seatNumber: firstSeat?.seat?.seatNumber ?? null,
|
seatNumber: firstSeat?.seat?.seatNumber ?? null,
|
||||||
origin: b.schedule.originStation.name,
|
origin: b.schedule.originStation.name,
|
||||||
destination: b.schedule.destinationStation.name,
|
destination: b.schedule.destinationStation.name,
|
||||||
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
phone: b.passenger?.user?.phone ?? (b as any).contactPhone ?? '—',
|
||||||
actualMinor,
|
actualMinor: effectiveActualMinor,
|
||||||
paidMinor,
|
paidMinor,
|
||||||
varianceMinor,
|
varianceMinor: effectiveVarianceMinor,
|
||||||
breakdown,
|
breakdown,
|
||||||
};
|
};
|
||||||
}).filter(r => r.varianceMinor > 0);
|
}).filter(r => r.varianceMinor > 0 && r.paidMinor > 0);
|
||||||
|
|
||||||
if (params.search?.trim()) {
|
if (params.search?.trim()) {
|
||||||
const q = params.search.trim().toUpperCase();
|
const q = params.search.trim().toUpperCase();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { apiClient } from '@/lib/api-client';
|
|||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface ScheduleOption { id: string; label: string; departureAt: string; }
|
interface ScheduleOption { id: string; label: string; departureAt: string; isPackage: boolean; }
|
||||||
|
|
||||||
interface PassengerBreakdown {
|
interface PassengerBreakdown {
|
||||||
passengerName: string;
|
passengerName: string;
|
||||||
@@ -22,6 +22,7 @@ interface PassengerBreakdown {
|
|||||||
|
|
||||||
interface DiscrepancyRow {
|
interface DiscrepancyRow {
|
||||||
bookingRef: string;
|
bookingRef: string;
|
||||||
|
isPackage: boolean;
|
||||||
seatClass: string;
|
seatClass: string;
|
||||||
coachNumber: string | null;
|
coachNumber: string | null;
|
||||||
seatNumber: string | null;
|
seatNumber: string | null;
|
||||||
@@ -81,10 +82,15 @@ function Pagination({ page, totalPages, setPage, total }: {
|
|||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function fmt(minor: number) {
|
function fmtMinor(minor: number) {
|
||||||
return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
|
return `ETB ${(minor / 100).toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// paidMinor from PaymentIntent.amountMinor is a Float stored as full units (not cents)
|
||||||
|
function fmtPaid(amount: number) {
|
||||||
|
return `ETB ${amount.toLocaleString('en-US', { minimumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
function downloadCsv(csv: string, filename: string) {
|
function downloadCsv(csv: string, filename: string) {
|
||||||
const blob = new Blob([csv], { type: 'text/csv' });
|
const blob = new Blob([csv], { type: 'text/csv' });
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
@@ -231,18 +237,10 @@ export default function PaymentsReportPage() {
|
|||||||
|
|
||||||
{data && (
|
{data && (
|
||||||
<div className="card overflow-x-auto">
|
<div className="card overflow-x-auto">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
<h3 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
{data.total} discrepanc{data.total !== 1 ? 'ies' : 'y'} found
|
{data.total} discrepanc{data.total !== 1 ? 'ies' : 'y'} found
|
||||||
</h3>
|
</h3>
|
||||||
{data.rows.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={doExport}
|
|
||||||
className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border border-border rounded-lg px-3 py-1.5 transition-colors"
|
|
||||||
>
|
|
||||||
<Download className="w-3.5 h-3.5" /> Export CSV
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -270,7 +268,14 @@ export default function PaymentsReportPage() {
|
|||||||
<td className="py-2 pr-2 text-muted-foreground">
|
<td className="py-2 pr-2 text-muted-foreground">
|
||||||
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-4 font-mono text-xs font-semibold">{r.bookingRef}</td>
|
<td className="py-2 pr-4 font-mono text-xs font-semibold">
|
||||||
|
{r.bookingRef}
|
||||||
|
{r.isPackage && (
|
||||||
|
<span className="ml-1.5 text-[10px] font-semibold bg-purple-100 dark:bg-purple-950/40 text-purple-700 dark:text-purple-300 border border-purple-200 dark:border-purple-800 px-1.5 py-0.5 rounded">
|
||||||
|
package
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="py-2 pr-4 text-xs">
|
<td className="py-2 pr-4 text-xs">
|
||||||
<span className="font-medium">{r.seatClass}</span>
|
<span className="font-medium">{r.seatClass}</span>
|
||||||
{r.coachNumber && <span className="text-muted-foreground"> · {r.coachNumber}</span>}
|
{r.coachNumber && <span className="text-muted-foreground"> · {r.coachNumber}</span>}
|
||||||
@@ -279,12 +284,12 @@ export default function PaymentsReportPage() {
|
|||||||
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
|
<td className="py-2 pr-4 text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{r.origin} → {r.destination}
|
{r.origin} → {r.destination}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmt(r.actualMinor)}</td>
|
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmtMinor(r.actualMinor)}</td>
|
||||||
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmt(r.paidMinor)}</td>
|
<td className="py-2 pr-4 text-right tabular-nums text-xs">{fmtPaid(r.paidMinor)}</td>
|
||||||
<td className="py-2 pr-4 text-right">
|
<td className="py-2 pr-4 text-right">
|
||||||
<span className="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-md bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
|
<span className="inline-flex items-center gap-1 text-xs font-bold px-2 py-0.5 rounded-md bg-red-50 dark:bg-red-950/30 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
|
||||||
<AlertTriangle className="w-3 h-3" />
|
<AlertTriangle className="w-3 h-3" />
|
||||||
{fmt(r.varianceMinor)}
|
{fmtMinor(r.varianceMinor)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-2 text-xs text-muted-foreground">{r.phone}</td>
|
<td className="py-2 text-xs text-muted-foreground">{r.phone}</td>
|
||||||
@@ -314,14 +319,14 @@ export default function PaymentsReportPage() {
|
|||||||
<td className="py-1.5 pr-4">{b.seatClass}</td>
|
<td className="py-1.5 pr-4">{b.seatClass}</td>
|
||||||
<td className="py-1.5 pr-4 font-mono">{b.coachNumber ?? '—'}</td>
|
<td className="py-1.5 pr-4 font-mono">{b.coachNumber ?? '—'}</td>
|
||||||
<td className="py-1.5 pr-4 font-mono">{b.seatNumber ?? '—'}</td>
|
<td className="py-1.5 pr-4 font-mono">{b.seatNumber ?? '—'}</td>
|
||||||
<td className="py-1.5 text-right tabular-nums font-semibold">{fmt(b.fareMinor)}</td>
|
<td className="py-1.5 text-right tabular-nums font-semibold">{fmtMinor(b.fareMinor)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
<tr className="border-t border-border font-semibold">
|
<tr className="border-t border-border font-semibold">
|
||||||
<td colSpan={4} className="pt-2 text-muted-foreground">Total actual vs paid</td>
|
<td colSpan={4} className="pt-2 text-muted-foreground">Total actual vs paid</td>
|
||||||
<td className="pt-2 text-right tabular-nums">
|
<td className="pt-2 text-right tabular-nums">
|
||||||
{fmt(r.actualMinor)} / {fmt(r.paidMinor)}
|
{fmtMinor(r.actualMinor)} / {fmtPaid(r.paidMinor)}
|
||||||
<span className="ml-2 text-red-500">(+{fmt(r.varianceMinor)})</span>
|
<span className="ml-2 text-red-500">(+{fmtMinor(r.varianceMinor)})</span>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user