'use client'; import { useState, useEffect } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api'; import { isManualProvider } from '@/lib/api/payments'; import { parseDate, formatRucDisplay } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { AdminPageSkeleton } from '@/components/ui/Skeleton'; import Input from '@/components/ui/Input'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; import { CheckCircleIcon, ArrowPathIcon, ArrowDownTrayIcon, DocumentArrowDownIcon, XCircleIcon, ClockIcon, ExclamationTriangleIcon, ChatBubbleLeftIcon, BoltIcon, BanknotesIcon, BuildingLibraryIcon, CalendarDaysIcon, CreditCardIcon, EnvelopeIcon, FunnelIcon, MagnifyingGlassIcon, XMarkIcon, } from '@heroicons/react/24/outline'; import toast from 'react-hot-toast'; import clsx from 'clsx'; type Tab = 'pending_approval' | 'all'; export default function AdminPaymentsPage() { const { t, locale } = useLanguage(); const [payments, setPayments] = useState([]); const [pendingApprovalPayments, setPendingApprovalPayments] = useState([]); // Manual-gateway payments still in bare 'pending': the customer may have paid // without clicking "I've paid" — approvable directly from the approval tab. const [unclaimedManualPayments, setUnclaimedManualPayments] = useState([]); const [events, setEvents] = useState([]); const [loading, setLoading] = useState(true); const [activeTab, setActiveTab] = useState('pending_approval'); const [statusFilter, setStatusFilter] = useState(''); const [providerFilter, setProviderFilter] = useState(''); const [eventFilter, setEventFilter] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); // Modal state const [selectedPayment, setSelectedPayment] = useState(null); const [noteText, setNoteText] = useState(''); const [processing, setProcessing] = useState(false); const [sendEmail, setSendEmail] = useState(true); const [sendingReminder, setSendingReminder] = useState(false); // Export state const [showExportModal, setShowExportModal] = useState(false); const [exporting, setExporting] = useState(false); const [exportData, setExportData] = useState<{ payments: ExportedPayment[]; summary: FinancialSummary } | null>(null); const [exportFilters, setExportFilters] = useState({ startDate: '', endDate: '', eventId: '', }); useEffect(() => { loadData(); }, [statusFilter, providerFilter, eventFilter]); const loadData = async () => { try { setLoading(true); const [pendingRes, allRes, unclaimedRes, eventsRes] = await Promise.all([ paymentsApi.getPendingApproval(), paymentsApi.getAll({ status: statusFilter || undefined, provider: providerFilter || undefined, eventIds: eventFilter.length > 0 ? eventFilter : undefined, }), paymentsApi.getAll({ status: 'pending' }), eventsApi.getAll(), ]); setPendingApprovalPayments(pendingRes.payments); setPayments(allRes.payments); setUnclaimedManualPayments(unclaimedRes.payments.filter(p => isManualProvider(p.provider))); setEvents(eventsRes.events); } catch (error) { toast.error('Failed to load payments'); } finally { setLoading(false); } }; // Approve with over-capacity confirmation: the backend rejects an approval // that would overbook the event unless the admin explicitly allows it. const approveWithCapacityConfirm = async (id: string, note?: string, email?: boolean) => { try { await paymentsApi.approve(id, note, email); } catch (error: any) { if (error?.code !== 'EVENT_OVER_CAPACITY') throw error; const seatsLeft = error?.data?.availableSeats ?? 0; const requested = error?.data?.requestedSeats ?? 1; const message = locale === 'es' ? `El evento está lleno (quedan ${seatsLeft} lugares, esta reserva necesita ${requested}). ¿Aprobar de todas formas y sobrevender?` : `This event is full (${seatsLeft} seat(s) left, this booking needs ${requested}). Approve anyway and overbook?`; if (!confirm(message)) return false; await paymentsApi.approve(id, note, email, true); } return true; }; const handleApprove = async (payment: PaymentWithDetails) => { setProcessing(true); try { const approved = await approveWithCapacityConfirm(payment.id, noteText, sendEmail); if (approved) { toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved'); setSelectedPayment(null); setNoteText(''); setSendEmail(true); loadData(); } } catch (error: any) { toast.error(error.message || 'Failed to approve payment'); } finally { setProcessing(false); } }; const handleReject = async (payment: PaymentWithDetails) => { setProcessing(true); try { await paymentsApi.reject(payment.id, noteText, sendEmail); toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected'); setSelectedPayment(null); setNoteText(''); setSendEmail(true); loadData(); } catch (error: any) { toast.error(error.message || 'Failed to reject payment'); } finally { setProcessing(false); } }; const handleSendReminder = async (payment: PaymentWithDetails) => { setSendingReminder(true); try { const result = await paymentsApi.sendReminder(payment.id); toast.success(locale === 'es' ? 'Recordatorio enviado' : 'Reminder sent'); // Update the selected payment with the new reminderSentAt timestamp if (result.reminderSentAt) { setSelectedPayment({ ...payment, reminderSentAt: result.reminderSentAt }); } // Also refresh the data to update the lists loadData(); } catch (error: any) { toast.error(error.message || 'Failed to send reminder'); } finally { setSendingReminder(false); } }; const handleConfirmPayment = async (id: string) => { try { const approved = await approveWithCapacityConfirm(id); if (approved) { toast.success('Payment confirmed'); loadData(); } } catch (error: any) { toast.error(error.message || 'Failed to confirm payment'); } }; const handleReactivate = async (payment: PaymentWithDetails) => { setProcessing(true); try { await paymentsApi.reactivate(payment.id); toast.success(locale === 'es' ? 'Reserva reactivada' : 'Booking reactivated'); setSelectedPayment(null); loadData(); } catch (error: any) { toast.error(error.message || 'Failed to reactivate booking'); } finally { setProcessing(false); } }; const handleReopen = async (payment: PaymentWithDetails) => { setProcessing(true); try { await paymentsApi.reopen(payment.id, noteText); toast.success(locale === 'es' ? 'Pago cambiado a pendiente' : 'Payment changed to pending'); setSelectedPayment(null); setNoteText(''); setSendEmail(true); loadData(); } catch (error: any) { toast.error(error.message || (locale === 'es' ? 'No se pudo reabrir el pago' : 'Failed to reopen payment')); } finally { setProcessing(false); } }; const handleRefund = async (id: string) => { if (!confirm('Are you sure you want to process this refund?')) return; try { await paymentsApi.refund(id); toast.success('Refund processed'); loadData(); } catch (error: any) { toast.error(error.message || 'Failed to process refund'); } }; const handleExport = async () => { setExporting(true); try { const data = await adminApi.exportFinancial({ startDate: exportFilters.startDate || undefined, endDate: exportFilters.endDate || undefined, eventId: exportFilters.eventId || undefined, }); setExportData(data); } catch (error) { toast.error('Failed to generate export'); } finally { setExporting(false); } }; const downloadCSV = () => { if (!exportData) return; const headers = ['Payment ID', 'Amount', 'Currency', 'Provider', 'Status', 'Reference', 'Paid At', 'Created At', 'Attendee Name', 'Attendee Email', 'RUC', 'Event Title', 'Event Date']; const rows = exportData.payments.map(p => [ p.paymentId, p.amount, p.currency, p.provider, p.status, p.reference || '', p.paidAt || '', p.createdAt, `${p.attendeeFirstName} ${p.attendeeLastName || ''}`.trim(), p.attendeeEmail || '', formatRucDisplay(p.attendeeRuc) || '', p.eventTitle, p.eventDate, ]); const csvContent = [headers, ...rows].map(row => row.map(cell => `"${cell}"`).join(',')).join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = `financial-export-${new Date().toISOString().split('T')[0]}.csv`; link.click(); toast.success('CSV downloaded'); }; const formatDate = (dateStr: string) => { return parseDate(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit', timeZone: 'America/Asuncion', }); }; const formatCurrency = (amount: number, currency: string) => { return `${amount.toLocaleString()} ${currency}`; }; const getStatusBadge = (status: string) => { const styles: Record = { pending: 'bg-gray-100 text-gray-700', pending_approval: 'bg-yellow-100 text-yellow-700', paid: 'bg-green-100 text-green-700', refunded: 'bg-blue-100 text-blue-700', failed: 'bg-red-100 text-red-700', cancelled: 'bg-gray-100 text-gray-700', on_hold: 'bg-slate-100 text-slate-600', }; const labels: Record = { pending: locale === 'es' ? 'Pendiente' : 'Pending', pending_approval: locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval', paid: locale === 'es' ? 'Pagado' : 'Paid', refunded: locale === 'es' ? 'Reembolsado' : 'Refunded', failed: locale === 'es' ? 'Fallido' : 'Failed', cancelled: locale === 'es' ? 'Cancelado' : 'Cancelled', on_hold: locale === 'es' ? 'En Espera' : 'On Hold', }; return ( {labels[status] || status} ); }; const getProviderIcon = (provider: string) => { const icons: Record = { lightning: BoltIcon, cash: BanknotesIcon, bank_transfer: BuildingLibraryIcon, tpago: CreditCardIcon, bancard: CreditCardIcon, }; const Icon = icons[provider] || CreditCardIcon; return ; }; const getProviderLabel = (provider: string) => { const labels: Record = { cash: locale === 'es' ? 'Efectivo' : 'Cash', bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer', lightning: 'Lightning', tpago: 'TPago', bancard: 'Bancard', }; return labels[provider] || provider; }; // Manual gateways need admin verification; automatic ones confirm themselves. const getProviderKindBadge = (provider: string) => ( isManualProvider(provider) ? ( {locale === 'es' ? 'Manual' : 'Manual'} ) : ( {locale === 'es' ? 'Automático' : 'Auto'} ) ); // Age of a claim/booking, e.g. "3h" / "2d"; used to surface rotting approvals. const getAgeInfo = (dateStr?: string | null) => { if (!dateStr) return null; const ms = Date.now() - parseDate(dateStr).getTime(); if (ms < 0) return null; const hours = Math.floor(ms / (60 * 60 * 1000)); const label = hours < 1 ? (locale === 'es' ? 'hace <1 h' : '<1h ago') : hours < 48 ? (locale === 'es' ? `hace ${hours} h` : `${hours}h ago`) : (locale === 'es' ? `hace ${Math.floor(hours / 24)} días` : `${Math.floor(hours / 24)}d ago`); return { hours, label, stale: hours >= 48 }; }; // Helper to get booking info for a payment (ticket count and total) const getBookingInfo = (payment: PaymentWithDetails) => { if (!payment.ticket?.bookingId) { return { ticketCount: 1, bookingTotal: payment.amount }; } // Count all payments with the same bookingId const bookingPayments = payments.filter( p => p.ticket?.bookingId === payment.ticket?.bookingId ); return { ticketCount: bookingPayments.length, bookingTotal: bookingPayments.reduce((sum, p) => sum + Number(p.amount), 0), }; }; // Hide pending-approval payments whose event has already ended. // Fall back to startDatetime when endDatetime is absent; keep visible when we // can't classify (event missing from list and no startDatetime on payment.event). const visiblePendingApprovalPayments = (() => { const now = new Date(); return pendingApprovalPayments.filter((payment) => { const eventId = payment.event?.id; const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined; const endIso = fullEvent?.endDatetime || fullEvent?.startDatetime || payment.event?.startDatetime; if (!endIso) return true; return parseDate(endIso).getTime() >= now.getTime(); }); })(); // Manual payments never claimed by the customer — they may have paid and // forgotten to press "I've paid", so they stay directly approvable here. // Hidden once the event has ended (same rule as pending approvals above). const visibleUnclaimedManualPayments = (() => { const now = new Date(); return unclaimedManualPayments.filter((payment) => { const eventId = payment.event?.id; const fullEvent = eventId ? events.find((e) => e.id === eventId) : undefined; const endIso = fullEvent?.endDatetime || fullEvent?.startDatetime || payment.event?.startDatetime; if (!endIso) return true; return parseDate(endIso).getTime() >= now.getTime(); }); })(); // Get booking info for pending approval payments const getPendingBookingInfo = (payment: PaymentWithDetails) => { if (!payment.ticket?.bookingId) { return { ticketCount: 1, bookingTotal: payment.amount }; } // Count all pending payments with the same bookingId const bookingPayments = visiblePendingApprovalPayments.filter( p => p.ticket?.bookingId === payment.ticket?.bookingId ); return { ticketCount: bookingPayments.length, bookingTotal: bookingPayments.reduce((sum, p) => sum + Number(p.amount), 0), }; }; // Calculate totals (sum all individual payment amounts). // Claimed ('pending_approval') money is probably already in the account and // just needs verification; bare 'pending' money may never arrive — keep the // two apart so the totals don't overstate what's owed. const totalAwaitingVerification = payments .filter(p => p.status === 'pending_approval') .reduce((sum, p) => sum + Number(p.amount), 0); const totalUnclaimed = payments .filter(p => p.status === 'pending') .reduce((sum, p) => sum + Number(p.amount), 0); const totalPaid = payments .filter(p => p.status === 'paid') .reduce((sum, p) => sum + Number(p.amount), 0); // Get unique booking count (for summary display) const getUniqueBookingsCount = (paymentsList: PaymentWithDetails[]) => { const seen = new Set(); let count = 0; paymentsList.forEach(p => { const bookingKey = p.ticket?.bookingId || p.id; if (!seen.has(bookingKey)) { seen.add(bookingKey); count++; } }); return count; }; const paidBookingsCount = getUniqueBookingsCount( payments.filter(p => p.status === 'paid') ); const pendingApprovalBookingsCount = getUniqueBookingsCount(visiblePendingApprovalPayments); const onHoldPayments = payments.filter(p => p.status === 'on_hold'); const onHoldBookingsCount = getUniqueBookingsCount(onHoldPayments); if (loading) { return ; } return (

{t('admin.payments.title')}

{/* Approval Detail Modal */} {selectedPayment && (() => { const modalBookingInfo = getBookingInfo(selectedPayment); return (

{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}

{locale === 'es' ? 'Monto Total' : 'Total Amount'}

{formatCurrency(modalBookingInfo.bookingTotal, selectedPayment.currency)}

{modalBookingInfo.ticketCount > 1 && (

📦 {modalBookingInfo.ticketCount} tickets × {formatCurrency(selectedPayment.amount, selectedPayment.currency)}

)}

{locale === 'es' ? 'Método' : 'Method'}

{getProviderIcon(selectedPayment.provider)} {getProviderLabel(selectedPayment.provider)}

{selectedPayment.ticket && (

{locale === 'es' ? 'Asistente' : 'Attendee'}

{selectedPayment.ticket.attendeeFirstName} {selectedPayment.ticket.attendeeLastName}

{selectedPayment.ticket.attendeeEmail}

{selectedPayment.ticket.attendeePhone && (

{selectedPayment.ticket.attendeePhone}

)} {selectedPayment.ticket.attendeeRuc && (

RUC: {formatRucDisplay(selectedPayment.ticket.attendeeRuc)}

)}
)} {selectedPayment.event && (

{locale === 'es' ? 'Evento' : 'Event'}

{selectedPayment.event.title}

{formatDate(selectedPayment.event.startDatetime)}

)} {/* Always shown — for payments the customer never confirmed, this is the only timestamp there is. */} {(() => { const age = getAgeInfo(selectedPayment.createdAt); return (
{locale === 'es' ? 'Reserva realizada:' : 'Booking made:'} {formatDate(selectedPayment.createdAt)} {age && ({age.label})}
); })()} {selectedPayment.userMarkedPaidAt && (() => { const age = getAgeInfo(selectedPayment.userMarkedPaidAt); return (
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)} {age && ({age.label})}
); })()} {selectedPayment.reminderSentAt && (
{locale === 'es' ? 'Recordatorio enviado:' : 'Reminder sent:'} {formatDate(selectedPayment.reminderSentAt)}
)} {selectedPayment.payerName && (

{locale === 'es' ? '⚠️ Pagado por otra persona:' : '⚠️ Paid by someone else:'}

{selectedPayment.payerName}

)}