mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: ( bookings ) search backoffice bookings by provider transaction ID
This commit is contained in:
@@ -152,6 +152,12 @@ export class BookingsController {
|
||||
@ApiQuery({ name: "returnLegStatus", required: false })
|
||||
@ApiQuery({ name: "bookingType", required: false })
|
||||
@ApiQuery({ name: "paymentStatus", required: false })
|
||||
@ApiQuery({
|
||||
name: "providerTxnId",
|
||||
required: false,
|
||||
description:
|
||||
"Payment provider transaction / order / merchant reference (partial, case-insensitive)",
|
||||
})
|
||||
@ApiQuery({ name: "dateFrom", required: false })
|
||||
@ApiQuery({ name: "dateTo", required: false })
|
||||
@ApiQuery({ name: "page", required: false })
|
||||
@@ -162,6 +168,7 @@ export class BookingsController {
|
||||
@Query("returnLegStatus") returnLegStatus?: string,
|
||||
@Query("bookingType") bookingType?: string,
|
||||
@Query("paymentStatus") paymentStatus?: string,
|
||||
@Query("providerTxnId") providerTxnId?: string,
|
||||
@Query("dateFrom") dateFrom?: string,
|
||||
@Query("dateTo") dateTo?: string,
|
||||
@Query("page") page?: string,
|
||||
@@ -173,6 +180,7 @@ export class BookingsController {
|
||||
returnLegStatus,
|
||||
bookingType,
|
||||
paymentStatus,
|
||||
providerTxnId,
|
||||
dateFrom,
|
||||
dateTo,
|
||||
page: page ? parseInt(page) : 1,
|
||||
|
||||
@@ -91,6 +91,7 @@ interface BookingFilters {
|
||||
returnLegStatus?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
providerTxnId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
page?: number;
|
||||
@@ -453,8 +454,9 @@ export class BookingsService {
|
||||
}
|
||||
|
||||
async findAll(filters: BookingFilters = {}) {
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const { search, status, returnLegStatus, bookingType, paymentStatus, providerTxnId, dateFrom, dateTo, page = 1, pageSize = 20 } = filters;
|
||||
const skip = (page - 1) * pageSize;
|
||||
const txn = providerTxnId?.trim() || undefined;
|
||||
|
||||
const onlyPackages = bookingType === 'PACKAGE';
|
||||
const includePackageBookings = !returnLegStatus && bookingType !== 'ONE_WAY' && bookingType !== 'ROUND_TRIP' && bookingType !== 'TRANSIT' && bookingType !== 'ROUND_TRIP_TRANSIT';
|
||||
@@ -495,11 +497,24 @@ export class BookingsService {
|
||||
...(dateTo ? { lte: new Date(new Date(dateTo).setHours(23, 59, 59, 999)) } : {}),
|
||||
};
|
||||
}
|
||||
// paymentStatus and providerTxnId both narrow the same relation — build one `is` filter
|
||||
// so the second doesn't overwrite the first.
|
||||
const paymentIntentIs: any = {};
|
||||
if (paymentStatus) {
|
||||
const statusMap: Record<string, string> = { PAID: 'SUCCEEDED', PENDING: 'REQUIRES_ACTION', FAILED: 'FAILED', REFUNDED: 'REFUNDED' };
|
||||
const mapped = statusMap[paymentStatus] ?? paymentStatus;
|
||||
where.paymentIntent = { is: { status: mapped } };
|
||||
paymentIntentIs.status = statusMap[paymentStatus] ?? paymentStatus;
|
||||
}
|
||||
if (txn) {
|
||||
// Providers are inconsistent about which reference they hand back to the customer —
|
||||
// match the transaction id, the provider/merchant order ids, and the generic ref.
|
||||
paymentIntentIs.OR = [
|
||||
{ providerTxnId: { contains: txn, mode: 'insensitive' } },
|
||||
{ providerOrderId: { contains: txn, mode: 'insensitive' } },
|
||||
{ merchantOrderId: { contains: txn, mode: 'insensitive' } },
|
||||
{ providerRef: { contains: txn, mode: 'insensitive' } },
|
||||
];
|
||||
}
|
||||
if (Object.keys(paymentIntentIs).length) where.paymentIntent = { is: paymentIntentIs };
|
||||
|
||||
const pkgWhere: any = {};
|
||||
if (search) {
|
||||
@@ -512,7 +527,12 @@ export class BookingsService {
|
||||
}
|
||||
if (status) pkgWhere.status = status;
|
||||
if (dateFrom || dateTo) pkgWhere.createdAt = where.createdAt;
|
||||
if (paymentStatus) pkgWhere.paymentIntent = { is: { status: (where.paymentIntent as any)?.is?.status } };
|
||||
const pkgPaymentIntentIs: any = {};
|
||||
if (paymentStatus) pkgPaymentIntentIs.status = paymentIntentIs.status;
|
||||
// PackagePaymentIntent has no providerTxnId/providerOrderId/merchantOrderId columns —
|
||||
// providerRef is the only reference we can match a package booking on.
|
||||
if (txn) pkgPaymentIntentIs.providerRef = { contains: txn, mode: 'insensitive' };
|
||||
if (Object.keys(pkgPaymentIntentIs).length) pkgWhere.paymentIntent = { is: pkgPaymentIntentIs };
|
||||
|
||||
if (onlyPackages) {
|
||||
// Package bookings live in two places:
|
||||
@@ -521,7 +541,7 @@ export class BookingsService {
|
||||
const bookingPkgWhere: any = { packageId: { not: null } };
|
||||
if (status) bookingPkgWhere.status = status;
|
||||
if (dateFrom || dateTo) bookingPkgWhere.createdAt = where.createdAt;
|
||||
if (paymentStatus) bookingPkgWhere.paymentIntent = where.paymentIntent;
|
||||
if (where.paymentIntent) bookingPkgWhere.paymentIntent = where.paymentIntent;
|
||||
if (search) bookingPkgWhere.OR = where.OR;
|
||||
|
||||
const [pkgItems, pkgTotal, regPkgItems, regPkgTotal] = await Promise.all([
|
||||
|
||||
@@ -32,7 +32,7 @@ const SectionHeader = ({ title }: { title: string }) => (
|
||||
function BookingsPageContent() {
|
||||
const canManage = usePermission(PERMS.bookings.manage);
|
||||
const [filters, setFilters] = useState<BookingFilters>({ page: 1, pageSize: 20, search: '', status: '' });
|
||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '' });
|
||||
const [extraFilters, setExtraFilters] = useState({ bookingType: '', dateFrom: '', dateTo: '', paymentStatus: '', providerTxnId: '' });
|
||||
const [showExtraFilters, setShowExtraFilters] = useState(false);
|
||||
const [selectedBooking, setSelectedBooking] = useState<any>(null);
|
||||
const [generateTicketBooking, setGenerateTicketBooking] = useState<any>(null);
|
||||
@@ -50,20 +50,26 @@ function BookingsPageContent() {
|
||||
const [exportDateTo, setExportDateTo] = useState('');
|
||||
const [exportColumns, setExportColumns] = useState<Record<string, boolean>>({
|
||||
bookingRef: true, bookingType: true, passengerNames: true, contactPhone: true,
|
||||
contactEmail: true, passengerCount: false, paymentStatus: true, totalMinor: true, status: true, createdAt: true,
|
||||
contactEmail: true, passengerCount: false, paymentStatus: true, providerTxnId: false, totalMinor: true, status: true, createdAt: true,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Single source of truth for the query params — the export path must send the same
|
||||
// filters as the table, otherwise exporting while filtered dumps every booking.
|
||||
const buildQueryFilters = (overrides: Partial<BookingFilters> = {}): BookingFilters => ({
|
||||
...filters,
|
||||
...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }),
|
||||
...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }),
|
||||
...(extraFilters.providerTxnId && { providerTxnId: extraFilters.providerTxnId }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['bookings', filters, extraFilters],
|
||||
queryFn: () => bookingsApi.getAll({
|
||||
...filters,
|
||||
...(extraFilters.bookingType && { bookingType: extraFilters.bookingType }),
|
||||
...(extraFilters.paymentStatus && { paymentStatus: extraFilters.paymentStatus }),
|
||||
...(extraFilters.dateFrom && { dateFrom: extraFilters.dateFrom }),
|
||||
...(extraFilters.dateTo && { dateTo: extraFilters.dateTo }),
|
||||
}),
|
||||
queryFn: () => bookingsApi.getAll(buildQueryFilters()),
|
||||
});
|
||||
|
||||
const smartAssignMutation = useMutation({
|
||||
@@ -112,7 +118,8 @@ function BookingsPageContent() {
|
||||
{ key: 'bookingRef', label: 'Booking Reference' }, { key: 'journeyType', label: 'Journey Type' },
|
||||
{ key: 'passengerNames', label: 'Passenger Names' }, { key: 'contactPhone', label: 'Contact Phone' },
|
||||
{ key: 'contactEmail', label: 'Contact Email' }, { key: 'passengerCount', label: 'Passenger Count' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' }, { key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'paymentStatus', label: 'Payment Status' }, { key: 'providerTxnId', label: 'Provider Txn ID' },
|
||||
{ key: 'totalMinor', label: 'Amount' },
|
||||
{ key: 'status', label: 'Status' }, { key: 'createdAt', label: 'Created At' },
|
||||
];
|
||||
|
||||
@@ -120,7 +127,7 @@ function BookingsPageContent() {
|
||||
const cols = Object.entries(exportColumns).filter(([, v]) => v).map(([k]) => k);
|
||||
if (!cols.length) { alert('Please select at least one column'); return; }
|
||||
// Fetch all records (not just current page)
|
||||
const allData = await bookingsApi.getAll({ ...filters, page: 1, pageSize: 9999 });
|
||||
const allData = await bookingsApi.getAll(buildQueryFilters({ page: 1, pageSize: 9999 }));
|
||||
const exportItems = (allData?.items || []).filter((b: any) => {
|
||||
if (!exportDateFrom && !exportDateTo) return true;
|
||||
const d = b.createdAt ? new Date(b.createdAt).toISOString().split('T')[0] : null;
|
||||
@@ -138,6 +145,7 @@ function BookingsPageContent() {
|
||||
case 'contactEmail': return booking.contactEmail || 'N/A';
|
||||
case 'passengerCount': return String((booking.adultCount ?? 0) + (booking.childCount ?? 0));
|
||||
case 'paymentStatus': return booking.paymentIntent?.status || 'PENDING';
|
||||
case 'providerTxnId': return booking.paymentIntent?.providerTxnId || 'N/A';
|
||||
case 'totalMinor': return formatCurrency(booking.totalMinor, booking.currency);
|
||||
case 'status': return booking.status;
|
||||
case 'createdAt': return booking.createdAt ? formatDateTime(booking.createdAt) : '';
|
||||
@@ -274,6 +282,11 @@ function BookingsPageContent() {
|
||||
<div>
|
||||
<Badge variant="status" status={booking.paymentIntent?.status || 'PENDING'}>{booking.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
<div className="text-sm text-muted-foreground">{formatCurrency(booking.displayTotalMinor ?? booking.totalMinor, booking.displayCurrency ?? booking.currency ?? 'ETB')}</div>
|
||||
{booking.paymentIntent?.providerTxnId && (
|
||||
<div className="text-xs font-mono text-muted-foreground truncate max-w-[10rem]" title={booking.paymentIntent.providerTxnId}>
|
||||
{booking.paymentIntent.providerTxnId}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -358,6 +371,12 @@ function BookingsPageContent() {
|
||||
<input type="date" className="input" value={extraFilters.dateTo}
|
||||
onChange={(e) => setExtraFilters({ ...extraFilters, dateTo: e.target.value })} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Provider Txn ID</label>
|
||||
<input type="text" className="input" placeholder="Transaction / order ref"
|
||||
value={extraFilters.providerTxnId}
|
||||
onChange={(e) => setExtraFilters({ ...extraFilters, providerTxnId: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -471,6 +490,9 @@ function BookingsPageContent() {
|
||||
<p className="text-xs text-muted-foreground mb-2">Payment Status</p>
|
||||
<Badge variant="status" status={b.paymentIntent?.status || 'PENDING'}>{b.paymentIntent?.status || 'PENDING'}</Badge>
|
||||
</div>
|
||||
<Field label="Payment Method" value={b.paymentIntent?.method} />
|
||||
<Field label="Provider Txn ID" value={b.paymentIntent?.providerTxnId} mono truncate />
|
||||
<Field label="Merchant Order ID" value={b.paymentIntent?.merchantOrderId} mono truncate />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export const bookingsApi = {
|
||||
if (filters?.status) params.append('status', filters.status);
|
||||
if (filters?.bookingType) params.append('bookingType', filters.bookingType);
|
||||
if (filters?.paymentStatus) params.append('paymentStatus', filters.paymentStatus);
|
||||
if (filters?.providerTxnId) params.append('providerTxnId', filters.providerTxnId);
|
||||
if (filters?.dateFrom) params.append('dateFrom', filters.dateFrom);
|
||||
if (filters?.dateTo) params.append('dateTo', filters.dateTo);
|
||||
if (filters?.search) params.append('search', filters.search);
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface BookingFilters {
|
||||
status?: string;
|
||||
bookingType?: string;
|
||||
paymentStatus?: string;
|
||||
/** Payment provider transaction / order / merchant reference — partial, case-insensitive. */
|
||||
providerTxnId?: string;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
search?: string;
|
||||
|
||||
Reference in New Issue
Block a user