- Bookings: align payment method labels with payments page (bank_transfer, tpago, etc), add sibling fallback - Payments: add event filter (single/multi), add search by name/email/event - Linktree: use Spanglish logo instead of icon - API: payments getAll supports eventId/eventIds Made-with: Cursor
1053 lines
52 KiB
TypeScript
1053 lines
52 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
import { useLanguage } from '@/context/LanguageContext';
|
||
import { paymentsApi, adminApi, eventsApi, PaymentWithDetails, Event, ExportedPayment, FinancialSummary } from '@/lib/api';
|
||
import Card from '@/components/ui/Card';
|
||
import Button from '@/components/ui/Button';
|
||
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,
|
||
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<PaymentWithDetails[]>([]);
|
||
const [pendingApprovalPayments, setPendingApprovalPayments] = useState<PaymentWithDetails[]>([]);
|
||
const [events, setEvents] = useState<Event[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [activeTab, setActiveTab] = useState<Tab>('pending_approval');
|
||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||
const [providerFilter, setProviderFilter] = useState<string>('');
|
||
const [eventFilter, setEventFilter] = useState<string[]>([]);
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||
|
||
// Modal state
|
||
const [selectedPayment, setSelectedPayment] = useState<PaymentWithDetails | null>(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, eventsRes] = await Promise.all([
|
||
paymentsApi.getPendingApproval(),
|
||
paymentsApi.getAll({
|
||
status: statusFilter || undefined,
|
||
provider: providerFilter || undefined,
|
||
eventIds: eventFilter.length > 0 ? eventFilter : undefined,
|
||
}),
|
||
eventsApi.getAll(),
|
||
]);
|
||
setPendingApprovalPayments(pendingRes.payments);
|
||
setPayments(allRes.payments);
|
||
setEvents(eventsRes.events);
|
||
} catch (error) {
|
||
toast.error('Failed to load payments');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleApprove = async (payment: PaymentWithDetails) => {
|
||
setProcessing(true);
|
||
try {
|
||
await paymentsApi.approve(payment.id, noteText, sendEmail);
|
||
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 {
|
||
await paymentsApi.approve(id);
|
||
toast.success('Payment confirmed');
|
||
loadData();
|
||
} catch (error) {
|
||
toast.error('Failed to confirm payment');
|
||
}
|
||
};
|
||
|
||
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', '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 || '',
|
||
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 new Date(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<string, string> = {
|
||
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',
|
||
};
|
||
const labels: Record<string, string> = {
|
||
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',
|
||
};
|
||
return (
|
||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${styles[status] || 'bg-gray-100 text-gray-700'}`}>
|
||
{labels[status] || status}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
const getProviderIcon = (provider: string) => {
|
||
const icons: Record<string, typeof BoltIcon> = {
|
||
lightning: BoltIcon,
|
||
cash: BanknotesIcon,
|
||
bank_transfer: BuildingLibraryIcon,
|
||
tpago: CreditCardIcon,
|
||
bancard: CreditCardIcon,
|
||
};
|
||
const Icon = icons[provider] || CreditCardIcon;
|
||
return <Icon className="w-4 h-4" />;
|
||
};
|
||
|
||
const getProviderLabel = (provider: string) => {
|
||
const labels: Record<string, string> = {
|
||
cash: locale === 'es' ? 'Efectivo' : 'Cash',
|
||
bank_transfer: locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer',
|
||
lightning: 'Lightning',
|
||
tpago: 'TPago',
|
||
bancard: 'Bancard',
|
||
};
|
||
return labels[provider] || provider;
|
||
};
|
||
|
||
// 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),
|
||
};
|
||
};
|
||
|
||
// 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 = pendingApprovalPayments.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)
|
||
const totalPending = payments
|
||
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
||
.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<string>();
|
||
let count = 0;
|
||
paymentsList.forEach(p => {
|
||
const bookingKey = p.ticket?.bookingId || p.id;
|
||
if (!seen.has(bookingKey)) {
|
||
seen.add(bookingKey);
|
||
count++;
|
||
}
|
||
});
|
||
return count;
|
||
};
|
||
|
||
const pendingBookingsCount = getUniqueBookingsCount(
|
||
payments.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
||
);
|
||
const paidBookingsCount = getUniqueBookingsCount(
|
||
payments.filter(p => p.status === 'paid')
|
||
);
|
||
const pendingApprovalBookingsCount = getUniqueBookingsCount(pendingApprovalPayments);
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center py-12">
|
||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
|
||
<Button onClick={() => setShowExportModal(true)} size="sm" className="min-h-[44px] md:min-h-0">
|
||
<DocumentArrowDownIcon className="w-4 h-4 mr-1.5" />
|
||
<span className="hidden md:inline">{locale === 'es' ? 'Exportar Datos' : 'Export Data'}</span>
|
||
<span className="md:hidden">{locale === 'es' ? 'Exportar' : 'Export'}</span>
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Approval Detail Modal */}
|
||
{selectedPayment && (() => {
|
||
const modalBookingInfo = getBookingInfo(selectedPayment);
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||
<Card className="w-full md:max-w-lg max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||
<h2 className="text-base font-bold">
|
||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||
</h2>
|
||
<button onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||
<XMarkIcon className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div className="p-4 overflow-y-auto flex-1 min-h-0">
|
||
|
||
<div className="space-y-4 mb-6">
|
||
<div className="bg-gray-50 rounded-lg p-4">
|
||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Monto Total' : 'Total Amount'}</p>
|
||
<p className="font-bold text-lg">{formatCurrency(modalBookingInfo.bookingTotal, selectedPayment.currency)}</p>
|
||
{modalBookingInfo.ticketCount > 1 && (
|
||
<div className="mt-2 p-2 bg-purple-50 rounded">
|
||
<p className="text-xs text-purple-700">
|
||
📦 {modalBookingInfo.ticketCount} tickets × {formatCurrency(selectedPayment.amount, selectedPayment.currency)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Método' : 'Method'}</p>
|
||
<p className="font-medium flex items-center gap-2">
|
||
{getProviderIcon(selectedPayment.provider)}
|
||
{getProviderLabel(selectedPayment.provider)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{selectedPayment.ticket && (
|
||
<div className="border rounded-lg p-4">
|
||
<h4 className="font-medium mb-2">{locale === 'es' ? 'Asistente' : 'Attendee'}</h4>
|
||
<p className="font-bold">
|
||
{selectedPayment.ticket.attendeeFirstName} {selectedPayment.ticket.attendeeLastName}
|
||
</p>
|
||
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeeEmail}</p>
|
||
{selectedPayment.ticket.attendeePhone && (
|
||
<p className="text-sm text-gray-600">{selectedPayment.ticket.attendeePhone}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{selectedPayment.event && (
|
||
<div className="border rounded-lg p-4">
|
||
<h4 className="font-medium mb-2">{locale === 'es' ? 'Evento' : 'Event'}</h4>
|
||
<p className="font-bold">{selectedPayment.event.title}</p>
|
||
<p className="text-sm text-gray-600">{formatDate(selectedPayment.event.startDatetime)}</p>
|
||
</div>
|
||
)}
|
||
|
||
{selectedPayment.userMarkedPaidAt && (
|
||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||
<ClockIcon className="w-4 h-4" />
|
||
{locale === 'es' ? 'Usuario marcó como pagado:' : 'User marked as paid:'} {formatDate(selectedPayment.userMarkedPaidAt)}
|
||
</div>
|
||
)}
|
||
|
||
{selectedPayment.reminderSentAt && (
|
||
<div className="flex items-center gap-2 text-sm text-amber-600">
|
||
<EnvelopeIcon className="w-4 h-4" />
|
||
{locale === 'es' ? 'Recordatorio enviado:' : 'Reminder sent:'} {formatDate(selectedPayment.reminderSentAt)}
|
||
</div>
|
||
)}
|
||
|
||
{selectedPayment.payerName && (
|
||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3">
|
||
<p className="text-sm text-amber-800 font-medium">
|
||
{locale === 'es' ? '⚠️ Pagado por otra persona:' : '⚠️ Paid by someone else:'}
|
||
</p>
|
||
<p className="text-amber-900 font-bold">{selectedPayment.payerName}</p>
|
||
</div>
|
||
)}
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">
|
||
{locale === 'es' ? 'Nota interna (opcional)' : 'Internal note (optional)'}
|
||
</label>
|
||
<textarea
|
||
value={noteText}
|
||
onChange={(e) => setNoteText(e.target.value)}
|
||
rows={2}
|
||
className="w-full px-4 py-2 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||
placeholder={locale === 'es' ? 'Agregar nota...' : 'Add a note...'}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2">
|
||
<input
|
||
type="checkbox"
|
||
id="sendEmail"
|
||
checked={sendEmail}
|
||
onChange={(e) => setSendEmail(e.target.checked)}
|
||
className="w-4 h-4 text-primary-yellow border-gray-300 rounded focus:ring-primary-yellow"
|
||
/>
|
||
<label htmlFor="sendEmail" className="text-sm text-gray-700">
|
||
{locale === 'es' ? 'Enviar email de notificación' : 'Send notification email'}
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex gap-3">
|
||
<Button onClick={() => handleApprove(selectedPayment)} isLoading={processing} className="flex-1 min-h-[44px]">
|
||
<CheckCircleIcon className="w-5 h-5 mr-2" />
|
||
{locale === 'es' ? 'Aprobar' : 'Approve'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => handleReject(selectedPayment)} isLoading={processing}
|
||
className="flex-1 border-red-300 text-red-600 hover:bg-red-50 min-h-[44px]">
|
||
<XCircleIcon className="w-5 h-5 mr-2" />
|
||
{locale === 'es' ? 'Rechazar' : 'Reject'}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="pt-2 border-t">
|
||
<Button variant="outline" onClick={() => handleSendReminder(selectedPayment)} isLoading={sendingReminder} className="w-full min-h-[44px]">
|
||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||
{locale === 'es' ? 'Enviar recordatorio' : 'Send reminder'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Export Modal */}
|
||
{showExportModal && (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-end md:items-center justify-center p-0 md:p-4">
|
||
<Card className="w-full md:max-w-2xl max-h-[90vh] flex flex-col overflow-hidden rounded-t-2xl md:rounded-card">
|
||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray flex-shrink-0">
|
||
<h2 className="text-base font-bold">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
|
||
<button onClick={() => { setShowExportModal(false); setExportData(null); }}
|
||
className="p-2 hover:bg-gray-100 rounded-btn min-h-[44px] min-w-[44px] flex items-center justify-center">
|
||
<XMarkIcon className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
<div className="p-4 overflow-y-auto flex-1 min-h-0">
|
||
|
||
{!exportData ? (
|
||
<div className="space-y-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<Input
|
||
label={locale === 'es' ? 'Fecha Inicio' : 'Start Date'}
|
||
type="date"
|
||
value={exportFilters.startDate}
|
||
onChange={(e) => setExportFilters({ ...exportFilters, startDate: e.target.value })}
|
||
/>
|
||
<Input
|
||
label={locale === 'es' ? 'Fecha Fin' : 'End Date'}
|
||
type="date"
|
||
value={exportFilters.endDate}
|
||
onChange={(e) => setExportFilters({ ...exportFilters, endDate: e.target.value })}
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento (opcional)' : 'Event (optional)'}</label>
|
||
<select
|
||
value={exportFilters.eventId}
|
||
onChange={(e) => setExportFilters({ ...exportFilters, eventId: e.target.value })}
|
||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||
>
|
||
<option value="">{locale === 'es' ? 'Todos los Eventos' : 'All Events'}</option>
|
||
{events.map((event) => (
|
||
<option key={event.id} value={event.id}>{event.title}</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div className="flex gap-3 pt-4">
|
||
<Button onClick={handleExport} isLoading={exporting} className="flex-1 min-h-[44px]">
|
||
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setShowExportModal(false)} className="flex-1 min-h-[44px]">
|
||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-6">
|
||
{/* Summary */}
|
||
<div className="bg-secondary-gray rounded-btn p-4">
|
||
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Resumen Financiero' : 'Financial Summary'}</h3>
|
||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
|
||
<p className="text-xl font-bold text-green-600">{exportData.summary.totalPaid.toLocaleString()} PYG</p>
|
||
<p className="text-xs text-gray-500">{exportData.summary.paidCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
||
<p className="text-xl font-bold text-yellow-600">{exportData.summary.totalPending.toLocaleString()} PYG</p>
|
||
<p className="text-xs text-gray-500">{exportData.summary.pendingCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Total Reembolsado' : 'Total Refunded'}</p>
|
||
<p className="text-xl font-bold text-blue-600">{exportData.summary.totalRefunded.toLocaleString()} PYG</p>
|
||
<p className="text-xs text-gray-500">{exportData.summary.refundedCount} {locale === 'es' ? 'pagos' : 'payments'}</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Total Registros' : 'Total Records'}</p>
|
||
<p className="text-xl font-bold">{exportData.summary.totalPayments}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* By Provider */}
|
||
<div className="bg-secondary-gray rounded-btn p-4">
|
||
<h3 className="font-semibold mb-4">{locale === 'es' ? 'Ingresos por Método' : 'Revenue by Method'}</h3>
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 text-sm">
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Efectivo' : 'Cash'}</p>
|
||
<p className="text-lg font-bold">{exportData.summary.byProvider.cash?.toLocaleString() || 0} PYG</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Lightning</p>
|
||
<p className="text-lg font-bold">{exportData.summary.byProvider.lightning?.toLocaleString() || 0} PYG</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</p>
|
||
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).bank_transfer?.toLocaleString() || 0} PYG</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">TPago</p>
|
||
<p className="text-lg font-bold">{(exportData.summary.byProvider as any).tpago?.toLocaleString() || 0} PYG</p>
|
||
</div>
|
||
<div>
|
||
<p className="text-gray-500">Bancard</p>
|
||
<p className="text-lg font-bold">{exportData.summary.byProvider.bancard?.toLocaleString() || 0} PYG</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex flex-wrap gap-3">
|
||
<Button onClick={downloadCSV} className="min-h-[44px]">
|
||
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
|
||
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setExportData(null)} className="min-h-[44px]">
|
||
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }} className="min-h-[44px]">
|
||
{locale === 'es' ? 'Cerrar' : 'Close'}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
)}
|
||
|
||
{/* Summary Cards */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||
<Card className="p-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Pendientes de Aprobación' : 'Pending Approval'}</p>
|
||
<p className="text-xl font-bold text-yellow-600">{pendingApprovalBookingsCount}</p>
|
||
{pendingApprovalPayments.length !== pendingApprovalBookingsCount && (
|
||
<p className="text-xs text-gray-400">({pendingApprovalPayments.length} tickets)</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card className="p-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center">
|
||
<ClockIcon className="w-5 h-5 text-gray-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pendiente' : 'Total Pending'}</p>
|
||
<p className="text-xl font-bold">{formatCurrency(totalPending, 'PYG')}</p>
|
||
<p className="text-xs text-gray-400">{pendingBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card className="p-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||
<CheckCircleIcon className="w-5 h-5 text-green-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Pagado' : 'Total Paid'}</p>
|
||
<p className="text-xl font-bold text-green-600">{formatCurrency(totalPaid, 'PYG')}</p>
|
||
<p className="text-xs text-gray-400">{paidBookingsCount} {locale === 'es' ? 'reservas' : 'bookings'}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
<Card className="p-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||
<BoltIcon className="w-5 h-5 text-blue-600" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-gray-500">{locale === 'es' ? 'Total Tickets' : 'Total Tickets'}</p>
|
||
<p className="text-xl font-bold">{payments.length}</p>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
</div>
|
||
|
||
{/* Tabs */}
|
||
<div className="border-b mb-6 overflow-x-auto scrollbar-hide">
|
||
<nav className="flex gap-4 min-w-max">
|
||
<button onClick={() => setActiveTab('pending_approval')}
|
||
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
||
activeTab === 'pending_approval' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||
{locale === 'es' ? 'Pendientes' : 'Pending Approval'}
|
||
{pendingApprovalPayments.length > 0 && (
|
||
<span className="ml-2 bg-yellow-100 text-yellow-700 px-2 py-0.5 rounded-full text-xs">{pendingApprovalPayments.length}</span>
|
||
)}
|
||
</button>
|
||
<button onClick={() => setActiveTab('all')}
|
||
className={clsx('pb-3 px-1 text-sm font-medium border-b-2 transition-colors whitespace-nowrap min-h-[44px]',
|
||
activeTab === 'all' ? 'border-primary-yellow text-primary-dark' : 'border-transparent text-gray-500 hover:text-gray-700')}>
|
||
{locale === 'es' ? 'Todos los Pagos' : 'All Payments'}
|
||
</button>
|
||
</nav>
|
||
</div>
|
||
|
||
{/* Pending Approval Tab */}
|
||
{activeTab === 'pending_approval' && (
|
||
<>
|
||
{pendingApprovalPayments.length === 0 ? (
|
||
<Card className="p-12 text-center">
|
||
<CheckCircleIcon className="w-12 h-12 text-green-400 mx-auto mb-4" />
|
||
<p className="text-gray-500">
|
||
{locale === 'es'
|
||
? 'No hay pagos pendientes de aprobación'
|
||
: 'No payments pending approval'}
|
||
</p>
|
||
</Card>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{pendingApprovalPayments.map((payment) => {
|
||
const bookingInfo = getPendingBookingInfo(payment);
|
||
return (
|
||
<Card key={payment.id} className="p-4">
|
||
<div className="flex items-start justify-between">
|
||
<div className="flex items-start gap-4">
|
||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center flex-shrink-0">
|
||
{getProviderIcon(payment.provider)}
|
||
</div>
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<p className="font-bold text-lg">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||
{bookingInfo.ticketCount > 1 && (
|
||
<span className="text-xs bg-purple-100 text-purple-700 px-2 py-0.5 rounded-full">
|
||
📦 {bookingInfo.ticketCount} tickets × {formatCurrency(payment.amount, payment.currency)}
|
||
</span>
|
||
)}
|
||
{getStatusBadge(payment.status)}
|
||
</div>
|
||
{payment.ticket && (
|
||
<p className="text-sm font-medium">
|
||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||
{bookingInfo.ticketCount > 1 && <span className="text-gray-400 font-normal"> +{bookingInfo.ticketCount - 1} {locale === 'es' ? 'más' : 'more'}</span>}
|
||
</p>
|
||
)}
|
||
{payment.event && (
|
||
<p className="text-sm text-gray-500">{payment.event.title}</p>
|
||
)}
|
||
<div className="flex items-center gap-4 mt-2 text-xs text-gray-400">
|
||
<span className="flex items-center gap-1">
|
||
{getProviderIcon(payment.provider)}
|
||
{getProviderLabel(payment.provider)}
|
||
</span>
|
||
{payment.userMarkedPaidAt && (
|
||
<span className="flex items-center gap-1">
|
||
<ClockIcon className="w-3 h-3" />
|
||
{locale === 'es' ? 'Marcado:' : 'Marked:'} {formatDate(payment.userMarkedPaidAt)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
{payment.payerName && (
|
||
<p className="text-xs text-amber-600 mt-1 font-medium">
|
||
⚠️ {locale === 'es' ? 'Pago por:' : 'Paid by:'} {payment.payerName}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<Button onClick={() => setSelectedPayment(payment)} size="sm" className="min-h-[44px] md:min-h-0 flex-shrink-0">
|
||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* All Payments Tab */}
|
||
{activeTab === 'all' && (() => {
|
||
const q = searchQuery.trim().toLowerCase();
|
||
const filteredPayments = q
|
||
? payments.filter((p) => {
|
||
const name = `${p.ticket?.attendeeFirstName || ''} ${p.ticket?.attendeeLastName || ''}`.trim().toLowerCase();
|
||
const email = (p.ticket?.attendeeEmail || '').toLowerCase();
|
||
const phone = (p.ticket?.attendeePhone || '').toLowerCase();
|
||
const eventTitle = (p.event?.title || '').toLowerCase();
|
||
const payerName = (p.payerName || '').toLowerCase();
|
||
const reference = (p.reference || '').toLowerCase();
|
||
const id = (p.id || '').toLowerCase();
|
||
return name.includes(q) || email.includes(q) || phone.includes(q) ||
|
||
eventTitle.includes(q) || payerName.includes(q) || reference.includes(q) || id.includes(q);
|
||
})
|
||
: payments;
|
||
|
||
return (
|
||
<>
|
||
{/* Desktop Filters */}
|
||
<Card className="p-4 mb-6 hidden md:block">
|
||
<div className="flex flex-wrap gap-4">
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Buscar' : 'Search'}</label>
|
||
<div className="relative">
|
||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow min-w-[200px]"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">Status</label>
|
||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
||
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
||
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
||
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
|
||
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
|
||
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
||
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
|
||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px] text-sm">
|
||
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
||
<option value="lightning">Lightning</option>
|
||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||
<option value="tpago">TPago</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex-1 min-w-[200px]">
|
||
<label className="block text-sm font-medium mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
|
||
<select
|
||
value=""
|
||
onChange={(e) => {
|
||
const id = e.target.value;
|
||
if (id && !eventFilter.includes(id)) setEventFilter([...eventFilter, id]);
|
||
e.target.value = '';
|
||
}}
|
||
className="px-4 py-2 rounded-btn border border-secondary-light-gray w-full text-sm"
|
||
>
|
||
<option value="">{locale === 'es' ? 'Agregar evento...' : 'Add event...'}</option>
|
||
{events.filter(e => !eventFilter.includes(e.id)).map((event) => (
|
||
<option key={event.id} value={event.id}>{event.title}</option>
|
||
))}
|
||
</select>
|
||
{eventFilter.length > 0 && (
|
||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||
{eventFilter.map((id) => {
|
||
const ev = events.find(e => e.id === id);
|
||
return (
|
||
<span key={id} className="inline-flex items-center gap-1 px-2 py-0.5 bg-primary-yellow/20 rounded text-xs">
|
||
{ev?.title || id}
|
||
<button type="button" onClick={() => setEventFilter(eventFilter.filter(x => x !== id))} className="hover:text-red-600">×</button>
|
||
</span>
|
||
);
|
||
})}
|
||
<button type="button" onClick={() => setEventFilter([])} className="text-xs text-gray-500 hover:text-primary-dark">
|
||
{locale === 'es' ? 'Limpiar' : 'Clear'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Mobile Search & Filter Toolbar */}
|
||
<div className="md:hidden mb-4 space-y-2">
|
||
<div className="relative">
|
||
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder={locale === 'es' ? 'Nombre, email, evento...' : 'Name, email, event...'}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="w-full pl-9 pr-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => setMobileFilterOpen(true)}
|
||
className={clsx('flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
||
(statusFilter || providerFilter || eventFilter.length > 0) ? 'border-primary-yellow bg-yellow-50 text-primary-dark' : 'border-secondary-light-gray text-gray-600')}>
|
||
<FunnelIcon className="w-4 h-4" /> Filters
|
||
</button>
|
||
{(statusFilter || providerFilter || eventFilter.length > 0 || searchQuery) && (
|
||
<button onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); }}
|
||
className="text-xs text-primary-yellow min-h-[44px] flex items-center">Clear</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Desktop: Table */}
|
||
<Card className="overflow-hidden hidden md:block">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-secondary-gray">
|
||
<tr>
|
||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
|
||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Evento' : 'Event'}</th>
|
||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Monto' : 'Amount'}</th>
|
||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Método' : 'Method'}</th>
|
||
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-secondary-light-gray">
|
||
{filteredPayments.length === 0 ? (
|
||
<tr><td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</td></tr>
|
||
) : (
|
||
filteredPayments.map((payment) => {
|
||
const bookingInfo = getBookingInfo(payment);
|
||
return (
|
||
<tr key={payment.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3">
|
||
{payment.ticket ? (
|
||
<div>
|
||
<p className="font-medium text-sm">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
|
||
<p className="text-xs text-gray-500 truncate max-w-[180px]">{payment.ticket.attendeeEmail}</p>
|
||
</div>
|
||
) : <span className="text-gray-400 text-sm">-</span>}
|
||
</td>
|
||
<td className="px-4 py-3 text-sm truncate max-w-[150px]">{payment.event?.title || '-'}</td>
|
||
<td className="px-4 py-3">
|
||
<p className="font-medium text-sm">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||
{bookingInfo.ticketCount > 1 && <p className="text-[10px] text-purple-600">{bookingInfo.ticketCount} tickets</p>}
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center gap-1.5 text-xs text-gray-600">
|
||
{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}
|
||
</div>
|
||
</td>
|
||
<td className="px-4 py-3">{getStatusBadge(payment.status)}</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex items-center justify-end gap-1">
|
||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2 py-1">
|
||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||
</Button>
|
||
)}
|
||
{payment.status === 'paid' && (
|
||
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2 py-1">
|
||
{t('admin.payments.refund')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
{(searchQuery || filteredPayments.length !== payments.length) && (
|
||
<p className="hidden md:block text-sm text-gray-500 mb-2">
|
||
{locale === 'es' ? 'Mostrando' : 'Showing'} {filteredPayments.length} {locale === 'es' ? 'de' : 'of'} {payments.length}
|
||
</p>
|
||
)}
|
||
|
||
{/* Mobile: Card List */}
|
||
<div className="md:hidden space-y-2">
|
||
{filteredPayments.length === 0 ? (
|
||
<div className="text-center py-10 text-gray-500 text-sm">{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}</div>
|
||
) : (
|
||
filteredPayments.map((payment) => {
|
||
const bookingInfo = getBookingInfo(payment);
|
||
return (
|
||
<Card key={payment.id} className="p-3">
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="min-w-0 flex-1">
|
||
{payment.ticket ? (
|
||
<p className="font-medium text-sm truncate">{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}</p>
|
||
) : <p className="text-sm text-gray-400">-</p>}
|
||
<p className="text-xs text-gray-500 truncate">{payment.event?.title || '-'}</p>
|
||
</div>
|
||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||
{getStatusBadge(payment.status)}
|
||
</div>
|
||
</div>
|
||
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
||
<span className="font-medium text-gray-700">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</span>
|
||
<span className="text-gray-300">|</span>
|
||
<span className="flex items-center gap-1">{getProviderIcon(payment.provider)} {getProviderLabel(payment.provider)}</span>
|
||
{bookingInfo.ticketCount > 1 && (
|
||
<><span className="text-gray-300">|</span><span className="text-purple-600">{bookingInfo.ticketCount} tickets</span></>
|
||
)}
|
||
</div>
|
||
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
||
<p className="text-[10px] text-gray-400">{formatDate(payment.createdAt)}</p>
|
||
<div className="flex items-center gap-1">
|
||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||
<Button size="sm" onClick={() => setSelectedPayment(payment)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||
</Button>
|
||
)}
|
||
{payment.status === 'paid' && (
|
||
<Button size="sm" variant="outline" onClick={() => handleRefund(payment.id)} className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
||
{t('admin.payments.refund')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
|
||
{/* Mobile Filter BottomSheet */}
|
||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title={locale === 'es' ? 'Filtros' : 'Filters'}>
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Evento(s)' : 'Event(s)'}</label>
|
||
<div className="max-h-40 overflow-y-auto border border-secondary-light-gray rounded-btn p-2 space-y-1">
|
||
{events.map((event) => (
|
||
<label key={event.id} className="flex items-center gap-2 py-1.5 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={eventFilter.includes(event.id)}
|
||
onChange={(e) => {
|
||
if (e.target.checked) setEventFilter([...eventFilter, event.id]);
|
||
else setEventFilter(eventFilter.filter(id => id !== event.id));
|
||
}}
|
||
className="w-4 h-4 rounded border-gray-300 text-primary-yellow focus:ring-primary-yellow"
|
||
/>
|
||
<span className="text-sm">{event.title}</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||
<select value={statusFilter} onChange={(e) => setStatusFilter(e.target.value)}
|
||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||
<option value="">{locale === 'es' ? 'Todos los Estados' : 'All Statuses'}</option>
|
||
<option value="pending">{locale === 'es' ? 'Pendiente' : 'Pending'}</option>
|
||
<option value="pending_approval">{locale === 'es' ? 'Esperando Aprobación' : 'Pending Approval'}</option>
|
||
<option value="paid">{locale === 'es' ? 'Pagado' : 'Paid'}</option>
|
||
<option value="refunded">{locale === 'es' ? 'Reembolsado' : 'Refunded'}</option>
|
||
<option value="failed">{locale === 'es' ? 'Fallido' : 'Failed'}</option>
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">{locale === 'es' ? 'Método' : 'Provider'}</label>
|
||
<select value={providerFilter} onChange={(e) => setProviderFilter(e.target.value)}
|
||
className="w-full px-3 py-2.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
||
<option value="">{locale === 'es' ? 'Todos los Métodos' : 'All Providers'}</option>
|
||
<option value="lightning">Lightning</option>
|
||
<option value="cash">{locale === 'es' ? 'Efectivo' : 'Cash'}</option>
|
||
<option value="bank_transfer">{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}</option>
|
||
<option value="tpago">TPago</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex gap-3 pt-2">
|
||
<Button variant="outline" onClick={() => { setStatusFilter(''); setProviderFilter(''); setEventFilter([]); setSearchQuery(''); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">Clear</Button>
|
||
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">Apply</Button>
|
||
</div>
|
||
</div>
|
||
</BottomSheet>
|
||
</>
|
||
);
|
||
})()}
|
||
|
||
<AdminMobileStyles />
|
||
</div>
|
||
);
|
||
}
|