917 lines
40 KiB
TypeScript
917 lines
40 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 {
|
||
CheckCircleIcon,
|
||
ArrowPathIcon,
|
||
ArrowDownTrayIcon,
|
||
DocumentArrowDownIcon,
|
||
XCircleIcon,
|
||
ClockIcon,
|
||
ExclamationTriangleIcon,
|
||
ChatBubbleLeftIcon,
|
||
BoltIcon,
|
||
BanknotesIcon,
|
||
BuildingLibraryIcon,
|
||
CreditCardIcon,
|
||
EnvelopeIcon,
|
||
} from '@heroicons/react/24/outline';
|
||
import toast from 'react-hot-toast';
|
||
|
||
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>('');
|
||
|
||
// 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]);
|
||
|
||
const loadData = async () => {
|
||
try {
|
||
setLoading(true);
|
||
const [pendingRes, allRes, eventsRes] = await Promise.all([
|
||
paymentsApi.getPendingApproval(),
|
||
paymentsApi.getAll({
|
||
status: statusFilter || undefined,
|
||
provider: providerFilter || 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-2xl font-bold text-primary-dark">{t('admin.payments.title')}</h1>
|
||
<Button onClick={() => setShowExportModal(true)}>
|
||
<DocumentArrowDownIcon className="w-5 h-5 mr-2" />
|
||
{locale === 'es' ? 'Exportar Datos' : 'Export Data'}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Approval Detail Modal */}
|
||
{selectedPayment && (() => {
|
||
const modalBookingInfo = getBookingInfo(selectedPayment);
|
||
return (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||
<Card className="w-full max-w-lg max-h-[90vh] overflow-y-auto p-6">
|
||
<h2 className="text-xl font-bold mb-4">
|
||
{locale === 'es' ? 'Verificar Pago' : 'Verify Payment'}
|
||
</h2>
|
||
|
||
<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"
|
||
>
|
||
<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"
|
||
>
|
||
<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"
|
||
>
|
||
<EnvelopeIcon className="w-5 h-5 mr-2" />
|
||
{locale === 'es' ? 'Enviar recordatorio de pago' : 'Send payment reminder'}
|
||
</Button>
|
||
</div>
|
||
|
||
<button
|
||
onClick={() => { setSelectedPayment(null); setNoteText(''); setSendEmail(true); }}
|
||
className="w-full mt-3 py-2 text-sm text-gray-500 hover:text-gray-700"
|
||
>
|
||
{locale === 'es' ? 'Cancelar' : 'Cancel'}
|
||
</button>
|
||
</Card>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Export Modal */}
|
||
{showExportModal && (
|
||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||
<Card className="w-full max-w-2xl max-h-[90vh] overflow-y-auto p-6">
|
||
<h2 className="text-xl font-bold mb-6">{locale === 'es' ? 'Exportar Datos Financieros' : 'Export Financial Data'}</h2>
|
||
|
||
{!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}>
|
||
{locale === 'es' ? 'Generar Reporte' : 'Generate Report'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setShowExportModal(false)}>
|
||
{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 gap-3">
|
||
<Button onClick={downloadCSV}>
|
||
<ArrowDownTrayIcon className="w-4 h-4 mr-2" />
|
||
{locale === 'es' ? 'Descargar CSV' : 'Download CSV'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => setExportData(null)}>
|
||
{locale === 'es' ? 'Nuevo Reporte' : 'New Report'}
|
||
</Button>
|
||
<Button variant="outline" onClick={() => { setShowExportModal(false); setExportData(null); }}>
|
||
{locale === 'es' ? 'Cerrar' : 'Close'}
|
||
</Button>
|
||
</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">
|
||
<nav className="flex gap-4">
|
||
<button
|
||
onClick={() => setActiveTab('pending_approval')}
|
||
className={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||
activeTab === 'pending_approval'
|
||
? 'border-primary-yellow text-primary-dark'
|
||
: 'border-transparent text-gray-500 hover:text-gray-700'
|
||
}`}
|
||
>
|
||
{locale === 'es' ? 'Pendientes de Aprobación' : '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={`pb-3 px-1 text-sm font-medium border-b-2 transition-colors ${
|
||
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)}>
|
||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||
</Button>
|
||
</div>
|
||
</Card>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* All Payments Tab */}
|
||
{activeTab === 'all' && (
|
||
<>
|
||
{/* Filters */}
|
||
<Card className="p-4 mb-6">
|
||
<div className="flex flex-wrap gap-4">
|
||
<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]"
|
||
>
|
||
<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]"
|
||
>
|
||
<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 Bancaria' : 'Bank Transfer'}</option>
|
||
<option value="tpago">TPago</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Payments Table */}
|
||
<Card className="overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full">
|
||
<thead className="bg-secondary-gray">
|
||
<tr>
|
||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Asistente' : 'Attendee'}</th>
|
||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Evento' : 'Event'}</th>
|
||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Monto' : 'Amount'}</th>
|
||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Método' : 'Method'}</th>
|
||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Fecha' : 'Date'}</th>
|
||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">{locale === 'es' ? 'Acciones' : 'Actions'}</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-secondary-light-gray">
|
||
{payments.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={7} className="px-6 py-12 text-center text-gray-500">
|
||
{locale === 'es' ? 'No se encontraron pagos' : 'No payments found'}
|
||
</td>
|
||
</tr>
|
||
) : (
|
||
payments.map((payment) => {
|
||
const bookingInfo = getBookingInfo(payment);
|
||
return (
|
||
<tr key={payment.id} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4">
|
||
{payment.ticket ? (
|
||
<div>
|
||
<p className="font-medium text-sm">
|
||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||
</p>
|
||
<p className="text-xs text-gray-500">{payment.ticket.attendeeEmail}</p>
|
||
{payment.payerName && (
|
||
<p className="text-xs text-amber-600 mt-1">
|
||
⚠️ {locale === 'es' ? 'Pagado por:' : 'Paid by:'} {payment.payerName}
|
||
</p>
|
||
)}
|
||
</div>
|
||
) : (
|
||
<span className="text-gray-400 text-sm">-</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
{payment.event ? (
|
||
<p className="text-sm">{payment.event.title}</p>
|
||
) : (
|
||
<span className="text-gray-400 text-sm">-</span>
|
||
)}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div>
|
||
<p className="font-medium">{formatCurrency(bookingInfo.bookingTotal, payment.currency)}</p>
|
||
{bookingInfo.ticketCount > 1 && (
|
||
<p className="text-xs text-purple-600 mt-1">
|
||
📦 {bookingInfo.ticketCount} × {formatCurrency(payment.amount, payment.currency)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||
{getProviderIcon(payment.provider)}
|
||
{getProviderLabel(payment.provider)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4 text-sm text-gray-600">
|
||
{formatDate(payment.createdAt)}
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="space-y-1">
|
||
{getStatusBadge(payment.status)}
|
||
{payment.ticket?.bookingId && (
|
||
<p className="text-xs text-purple-600" title="Part of multi-ticket booking">
|
||
📦 {locale === 'es' ? 'Grupo' : 'Group'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</td>
|
||
<td className="px-6 py-4">
|
||
<div className="flex items-center justify-end gap-2">
|
||
{(payment.status === 'pending' || payment.status === 'pending_approval') && (
|
||
<Button
|
||
size="sm"
|
||
onClick={() => setSelectedPayment(payment)}
|
||
>
|
||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||
{locale === 'es' ? 'Revisar' : 'Review'}
|
||
</Button>
|
||
)}
|
||
{payment.status === 'paid' && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() => handleRefund(payment.id)}
|
||
>
|
||
<ArrowPathIcon className="w-4 h-4 mr-1" />
|
||
{t('admin.payments.refund')}
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
);
|
||
})
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</Card>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|