first commit
This commit is contained in:
426
frontend/src/app/admin/bookings/page.tsx
Normal file
426
frontend/src/app/admin/bookings/page.tsx
Normal file
@@ -0,0 +1,426 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
TicketIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
CurrencyDollarIcon,
|
||||
UserIcon,
|
||||
EnvelopeIcon,
|
||||
PhoneIcon,
|
||||
FunnelIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface TicketWithDetails extends Omit<Ticket, 'payment'> {
|
||||
event?: Event;
|
||||
payment?: {
|
||||
id: string;
|
||||
ticketId?: string;
|
||||
provider: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
status: string;
|
||||
reference?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdminBookingsPage() {
|
||||
const { locale } = useLanguage();
|
||||
const [tickets, setTickets] = useState<TicketWithDetails[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [processing, setProcessing] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
||||
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [ticketsRes, eventsRes] = await Promise.all([
|
||||
ticketsApi.getAll(),
|
||||
eventsApi.getAll(),
|
||||
]);
|
||||
|
||||
// Fetch full ticket details with payment info
|
||||
const ticketsWithDetails = await Promise.all(
|
||||
ticketsRes.tickets.map(async (ticket) => {
|
||||
try {
|
||||
const { ticket: fullTicket } = await ticketsApi.getById(ticket.id);
|
||||
return fullTicket;
|
||||
} catch {
|
||||
return ticket;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
setTickets(ticketsWithDetails);
|
||||
setEvents(eventsRes.events);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load bookings');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkPaid = async (ticketId: string) => {
|
||||
setProcessing(ticketId);
|
||||
try {
|
||||
await ticketsApi.markPaid(ticketId);
|
||||
toast.success('Payment marked as received');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to mark payment');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckin = async (ticketId: string) => {
|
||||
setProcessing(ticketId);
|
||||
try {
|
||||
await ticketsApi.checkin(ticketId);
|
||||
toast.success('Check-in successful');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to check in');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (ticketId: string) => {
|
||||
if (!confirm('Are you sure you want to cancel this booking?')) return;
|
||||
|
||||
setProcessing(ticketId);
|
||||
try {
|
||||
await ticketsApi.cancel(ticketId);
|
||||
toast.success('Booking cancelled');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to cancel');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'confirmed':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'checked_in':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'paid':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'failed':
|
||||
case 'cancelled':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'refunded':
|
||||
return 'bg-purple-100 text-purple-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentMethodLabel = (provider: string) => {
|
||||
switch (provider) {
|
||||
case 'bancard':
|
||||
return 'TPago / Card';
|
||||
case 'lightning':
|
||||
return 'Bitcoin Lightning';
|
||||
case 'cash':
|
||||
return 'Cash at Event';
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
};
|
||||
|
||||
// Filter tickets
|
||||
const filteredTickets = tickets.filter((ticket) => {
|
||||
if (selectedEvent && ticket.eventId !== selectedEvent) return false;
|
||||
if (selectedStatus && ticket.status !== selectedStatus) return false;
|
||||
if (selectedPaymentStatus && ticket.payment?.status !== selectedPaymentStatus) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Sort by created date (newest first)
|
||||
const sortedTickets = [...filteredTickets].sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
|
||||
// Stats
|
||||
const stats = {
|
||||
total: tickets.length,
|
||||
pending: tickets.filter(t => t.status === 'pending').length,
|
||||
confirmed: tickets.filter(t => t.status === 'confirmed').length,
|
||||
checkedIn: tickets.filter(t => t.status === 'checked_in').length,
|
||||
cancelled: tickets.filter(t => t.status === 'cancelled').length,
|
||||
pendingPayment: tickets.filter(t => t.payment?.status === 'pending').length,
|
||||
};
|
||||
|
||||
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">Manage Bookings</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-6">
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-2xl font-bold text-primary-dark">{stats.total}</p>
|
||||
<p className="text-sm text-gray-500">Total</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-yellow-400">
|
||||
<p className="text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
||||
<p className="text-sm text-gray-500">Pending</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-green-400">
|
||||
<p className="text-2xl font-bold text-green-600">{stats.confirmed}</p>
|
||||
<p className="text-sm text-gray-500">Confirmed</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-blue-400">
|
||||
<p className="text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
|
||||
<p className="text-sm text-gray-500">Checked In</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-red-400">
|
||||
<p className="text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
||||
<p className="text-sm text-gray-500">Cancelled</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center border-l-4 border-orange-400">
|
||||
<p className="text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
||||
<p className="text-sm text-gray-500">Pending Payment</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<FunnelIcon className="w-5 h-5 text-gray-500" />
|
||||
<span className="font-medium">Filters</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Event</label>
|
||||
<select
|
||||
value={selectedEvent}
|
||||
onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Booking Status</label>
|
||||
<select
|
||||
value={selectedStatus}
|
||||
onChange={(e) => setSelectedStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="checked_in">Checked In</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
||||
<select
|
||||
value={selectedPaymentStatus}
|
||||
onChange={(e) => setSelectedPaymentStatus(e.target.value)}
|
||||
className="w-full px-3 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">All Payment Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="paid">Paid</option>
|
||||
<option value="refunded">Refunded</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bookings List */}
|
||||
<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">Attendee</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Payment</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Status</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{sortedTickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
|
||||
No bookings found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
sortedTickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-4 h-4 text-gray-400" />
|
||||
<span className="font-medium">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<EnvelopeIcon className="w-4 h-4" />
|
||||
<span>{ticket.attendeeEmail || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<PhoneIcon className="w-4 h-4" />
|
||||
<span>{ticket.attendeePhone || 'N/A'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-sm">
|
||||
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="space-y-1">
|
||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
||||
{ticket.payment?.status || 'pending'}
|
||||
</span>
|
||||
<p className="text-sm text-gray-500">
|
||||
{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}
|
||||
</p>
|
||||
{ticket.payment && (
|
||||
<p className="text-sm font-medium">
|
||||
{ticket.payment.amount?.toLocaleString()} {ticket.payment.currency}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className={`inline-block px-2 py-1 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
{ticket.qrCode && (
|
||||
<p className="text-xs text-gray-400 mt-1 font-mono">{ticket.qrCode}</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(ticket.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Mark as Paid (for pending payments) */}
|
||||
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleMarkPaid(ticket.id)}
|
||||
isLoading={processing === ticket.id}
|
||||
className="text-green-600 hover:bg-green-50"
|
||||
>
|
||||
<CurrencyDollarIcon className="w-4 h-4 mr-1" />
|
||||
Mark Paid
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Check-in (for confirmed tickets) */}
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleCheckin(ticket.id)}
|
||||
isLoading={processing === ticket.id}
|
||||
className="text-blue-600 hover:bg-blue-50"
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||
Check In
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Cancel (for pending/confirmed) */}
|
||||
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleCancel(ticket.id)}
|
||||
isLoading={processing === ticket.id}
|
||||
className="text-red-600 hover:bg-red-50"
|
||||
>
|
||||
<XCircleIcon className="w-4 h-4 mr-1" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{ticket.status === 'checked_in' && (
|
||||
<span className="text-sm text-green-600 flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-4 h-4" />
|
||||
Attended
|
||||
</span>
|
||||
)}
|
||||
|
||||
{ticket.status === 'cancelled' && (
|
||||
<span className="text-sm text-gray-400">Cancelled</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
198
frontend/src/app/admin/contacts/page.tsx
Normal file
198
frontend/src/app/admin/contacts/page.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { contactsApi, Contact } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { EnvelopeIcon, EnvelopeOpenIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function AdminContactsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [contacts, setContacts] = useState<Contact[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadContacts();
|
||||
}, [statusFilter]);
|
||||
|
||||
const loadContacts = async () => {
|
||||
try {
|
||||
const { contacts } = await contactsApi.getAll(statusFilter || undefined);
|
||||
setContacts(contacts);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load contacts');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id: string, status: string) => {
|
||||
try {
|
||||
await contactsApi.updateStatus(id, status);
|
||||
toast.success('Status updated');
|
||||
loadContacts();
|
||||
if (selectedContact?.id === id) {
|
||||
setSelectedContact({ ...selectedContact, status: status as any });
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to update status');
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
new: 'badge-info',
|
||||
read: 'badge-warning',
|
||||
replied: 'badge-success',
|
||||
};
|
||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
||||
};
|
||||
|
||||
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.nav.contacts')}</h1>
|
||||
</div>
|
||||
|
||||
{/* 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="">All</option>
|
||||
<option value="new">New</option>
|
||||
<option value="read">Read</option>
|
||||
<option value="replied">Replied</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Messages List */}
|
||||
<div className="lg:col-span-1">
|
||||
<Card className="divide-y divide-secondary-light-gray max-h-[600px] overflow-y-auto">
|
||||
{contacts.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
<EnvelopeIcon className="w-12 h-12 mx-auto mb-2 text-gray-300" />
|
||||
<p>No messages</p>
|
||||
</div>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<button
|
||||
key={contact.id}
|
||||
onClick={() => {
|
||||
setSelectedContact(contact);
|
||||
if (contact.status === 'new') {
|
||||
handleStatusChange(contact.id, 'read');
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left p-4 hover:bg-gray-50 transition-colors ${
|
||||
selectedContact?.id === contact.id ? 'bg-secondary-gray' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{contact.status === 'new' ? (
|
||||
<EnvelopeIcon className="w-4 h-4 text-secondary-blue" />
|
||||
) : (
|
||||
<EnvelopeOpenIcon className="w-4 h-4 text-gray-400" />
|
||||
)}
|
||||
<span className={`font-medium text-sm ${contact.status === 'new' ? 'text-primary-dark' : 'text-gray-600'}`}>
|
||||
{contact.name}
|
||||
</span>
|
||||
</div>
|
||||
{getStatusBadge(contact.status)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">{contact.email}</p>
|
||||
<p className="mt-1 text-sm text-gray-600 truncate">{contact.message}</p>
|
||||
<p className="mt-2 text-xs text-gray-400">{formatDate(contact.createdAt)}</p>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Message Detail */}
|
||||
<div className="lg:col-span-2">
|
||||
<Card className="p-6 min-h-[400px]">
|
||||
{selectedContact ? (
|
||||
<div>
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold">{selectedContact.name}</h2>
|
||||
<a
|
||||
href={`mailto:${selectedContact.email}`}
|
||||
className="text-secondary-blue hover:underline"
|
||||
>
|
||||
{selectedContact.email}
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedContact.status !== 'replied' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleStatusChange(selectedContact.id, 'replied')}
|
||||
>
|
||||
<CheckIcon className="w-4 h-4 mr-1" />
|
||||
Mark as Replied
|
||||
</Button>
|
||||
)}
|
||||
<a href={`mailto:${selectedContact.email}`}>
|
||||
<Button size="sm">
|
||||
Reply
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-secondary-light-gray pt-6">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Received: {formatDate(selectedContact.createdAt)}
|
||||
</p>
|
||||
<div className="prose prose-sm max-w-none">
|
||||
<p className="whitespace-pre-wrap text-gray-700">{selectedContact.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full text-gray-500">
|
||||
<div className="text-center">
|
||||
<EnvelopeIcon className="w-16 h-16 mx-auto mb-4 text-gray-300" />
|
||||
<p>Select a message to view</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
961
frontend/src/app/admin/emails/page.tsx
Normal file
961
frontend/src/app/admin/emails/page.tsx
Normal file
@@ -0,0 +1,961 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { emailsApi, EmailTemplate, EmailLog, EmailStats } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
PencilIcon,
|
||||
DocumentDuplicateIcon,
|
||||
EyeIcon,
|
||||
PaperAirplaneIcon,
|
||||
ClockIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type TabType = 'templates' | 'logs' | 'compose';
|
||||
|
||||
const DRAFT_STORAGE_KEY = 'spanglish-email-draft';
|
||||
|
||||
interface EmailDraft {
|
||||
eventId: string;
|
||||
templateSlug: string;
|
||||
customSubject: string;
|
||||
customBody: string;
|
||||
recipientFilter: 'all' | 'confirmed' | 'pending' | 'checked_in';
|
||||
savedAt: string;
|
||||
}
|
||||
|
||||
export default function AdminEmailsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [activeTab, setActiveTab] = useState<TabType>('templates');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Templates state
|
||||
const [templates, setTemplates] = useState<EmailTemplate[]>([]);
|
||||
const [editingTemplate, setEditingTemplate] = useState<EmailTemplate | null>(null);
|
||||
const [showTemplateForm, setShowTemplateForm] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Logs state
|
||||
const [logs, setLogs] = useState<EmailLog[]>([]);
|
||||
const [logsOffset, setLogsOffset] = useState(0);
|
||||
const [logsTotal, setLogsTotal] = useState(0);
|
||||
const [selectedLog, setSelectedLog] = useState<EmailLog | null>(null);
|
||||
|
||||
// Stats state
|
||||
const [stats, setStats] = useState<EmailStats | null>(null);
|
||||
|
||||
// Preview state
|
||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||
const [previewSubject, setPreviewSubject] = useState<string>('');
|
||||
|
||||
// Template form state
|
||||
const [templateForm, setTemplateForm] = useState({
|
||||
name: '',
|
||||
slug: '',
|
||||
subject: '',
|
||||
subjectEs: '',
|
||||
bodyHtml: '',
|
||||
bodyHtmlEs: '',
|
||||
bodyText: '',
|
||||
bodyTextEs: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
// Compose/Draft state
|
||||
const [events, setEvents] = useState<any[]>([]);
|
||||
const [composeForm, setComposeForm] = useState<EmailDraft>({
|
||||
eventId: '',
|
||||
templateSlug: '',
|
||||
customSubject: '',
|
||||
customBody: '',
|
||||
recipientFilter: 'confirmed',
|
||||
savedAt: '',
|
||||
});
|
||||
const [hasDraft, setHasDraft] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showRecipientPreview, setShowRecipientPreview] = useState(false);
|
||||
const [previewRecipients, setPreviewRecipients] = useState<any[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
loadEvents();
|
||||
loadDraft();
|
||||
}, []);
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/events', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setEvents(data.events || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load events');
|
||||
}
|
||||
};
|
||||
|
||||
const loadDraft = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const draft = JSON.parse(saved) as EmailDraft;
|
||||
setComposeForm(draft);
|
||||
setHasDraft(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load draft');
|
||||
}
|
||||
};
|
||||
|
||||
const saveDraft = () => {
|
||||
try {
|
||||
const draft: EmailDraft = {
|
||||
...composeForm,
|
||||
savedAt: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft));
|
||||
setHasDraft(true);
|
||||
toast.success('Draft saved');
|
||||
} catch (error) {
|
||||
toast.error('Failed to save draft');
|
||||
}
|
||||
};
|
||||
|
||||
const clearDraft = () => {
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
setComposeForm({
|
||||
eventId: '',
|
||||
templateSlug: '',
|
||||
customSubject: '',
|
||||
customBody: '',
|
||||
recipientFilter: 'confirmed',
|
||||
savedAt: '',
|
||||
});
|
||||
setHasDraft(false);
|
||||
};
|
||||
|
||||
const loadRecipientPreview = async () => {
|
||||
if (!composeForm.eventId) {
|
||||
toast.error('Please select an event');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/events/${composeForm.eventId}/attendees`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
let attendees = data.attendees || [];
|
||||
|
||||
// Apply filter
|
||||
if (composeForm.recipientFilter !== 'all') {
|
||||
attendees = attendees.filter((a: any) => a.status === composeForm.recipientFilter);
|
||||
}
|
||||
|
||||
setPreviewRecipients(attendees);
|
||||
setShowRecipientPreview(true);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load recipients');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendEmail = async () => {
|
||||
if (!composeForm.eventId || !composeForm.templateSlug) {
|
||||
toast.error('Please select an event and template');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to send this email to ${previewRecipients.length} recipients?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await emailsApi.sendToEvent(composeForm.eventId, {
|
||||
templateSlug: composeForm.templateSlug,
|
||||
recipientFilter: composeForm.recipientFilter,
|
||||
customVariables: composeForm.customBody ? { customMessage: composeForm.customBody } : undefined,
|
||||
});
|
||||
|
||||
if (res.success || res.sentCount > 0) {
|
||||
toast.success(`Sent ${res.sentCount} emails successfully`);
|
||||
if (res.failedCount > 0) {
|
||||
toast.error(`${res.failedCount} emails failed`);
|
||||
}
|
||||
clearDraft();
|
||||
setShowRecipientPreview(false);
|
||||
} else {
|
||||
toast.error('Failed to send emails');
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to send emails');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'logs') {
|
||||
loadLogs();
|
||||
}
|
||||
}, [activeTab, logsOffset]);
|
||||
|
||||
const loadData = async () => {
|
||||
try {
|
||||
const [templatesRes, statsRes] = await Promise.all([
|
||||
emailsApi.getTemplates(),
|
||||
emailsApi.getStats(),
|
||||
]);
|
||||
setTemplates(templatesRes.templates);
|
||||
setStats(statsRes.stats);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load email data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadLogs = async () => {
|
||||
try {
|
||||
const res = await emailsApi.getLogs({ limit: 20, offset: logsOffset });
|
||||
setLogs(res.logs);
|
||||
setLogsTotal(res.pagination.total);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load email logs');
|
||||
}
|
||||
};
|
||||
|
||||
const resetTemplateForm = () => {
|
||||
setTemplateForm({
|
||||
name: '',
|
||||
slug: '',
|
||||
subject: '',
|
||||
subjectEs: '',
|
||||
bodyHtml: '',
|
||||
bodyHtmlEs: '',
|
||||
bodyText: '',
|
||||
bodyTextEs: '',
|
||||
description: '',
|
||||
isActive: true,
|
||||
});
|
||||
setEditingTemplate(null);
|
||||
};
|
||||
|
||||
const handleEditTemplate = (template: EmailTemplate) => {
|
||||
setTemplateForm({
|
||||
name: template.name,
|
||||
slug: template.slug,
|
||||
subject: template.subject,
|
||||
subjectEs: template.subjectEs || '',
|
||||
bodyHtml: template.bodyHtml,
|
||||
bodyHtmlEs: template.bodyHtmlEs || '',
|
||||
bodyText: template.bodyText || '',
|
||||
bodyTextEs: template.bodyTextEs || '',
|
||||
description: template.description || '',
|
||||
isActive: template.isActive,
|
||||
});
|
||||
setEditingTemplate(template);
|
||||
setShowTemplateForm(true);
|
||||
};
|
||||
|
||||
const handleSaveTemplate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const data = {
|
||||
...templateForm,
|
||||
subjectEs: templateForm.subjectEs || undefined,
|
||||
bodyHtmlEs: templateForm.bodyHtmlEs || undefined,
|
||||
bodyText: templateForm.bodyText || undefined,
|
||||
bodyTextEs: templateForm.bodyTextEs || undefined,
|
||||
description: templateForm.description || undefined,
|
||||
};
|
||||
|
||||
if (editingTemplate) {
|
||||
await emailsApi.updateTemplate(editingTemplate.id, data);
|
||||
toast.success('Template updated');
|
||||
} else {
|
||||
await emailsApi.createTemplate(data);
|
||||
toast.success('Template created');
|
||||
}
|
||||
|
||||
setShowTemplateForm(false);
|
||||
resetTemplateForm();
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save template');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePreviewTemplate = async (template: EmailTemplate) => {
|
||||
try {
|
||||
const res = await emailsApi.preview({
|
||||
templateSlug: template.slug,
|
||||
variables: {
|
||||
attendeeName: 'John Doe',
|
||||
attendeeEmail: 'john@example.com',
|
||||
ticketId: 'TKT-ABC123',
|
||||
eventTitle: 'Spanglish Night - January Edition',
|
||||
eventDate: 'January 28, 2026',
|
||||
eventTime: '7:00 PM',
|
||||
eventLocation: 'Casa Cultural, Asunción',
|
||||
eventLocationUrl: 'https://maps.google.com',
|
||||
eventPrice: '50,000 PYG',
|
||||
paymentAmount: '50,000 PYG',
|
||||
paymentMethod: 'Lightning',
|
||||
paymentReference: 'PAY-XYZ789',
|
||||
paymentDate: 'January 28, 2026',
|
||||
customMessage: 'This is a preview message.',
|
||||
},
|
||||
locale,
|
||||
});
|
||||
setPreviewSubject(res.subject);
|
||||
setPreviewHtml(res.bodyHtml);
|
||||
} catch (error) {
|
||||
toast.error('Failed to preview template');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this template?')) return;
|
||||
|
||||
try {
|
||||
await emailsApi.deleteTemplate(id);
|
||||
toast.success('Template deleted');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete template');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
return <CheckCircleIcon className="w-5 h-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="w-5 h-5 text-red-500" />;
|
||||
case 'pending':
|
||||
return <ClockIcon className="w-5 h-5 text-yellow-500" />;
|
||||
case 'bounced':
|
||||
return <ExclamationTriangleIcon className="w-5 h-5 text-orange-500" />;
|
||||
default:
|
||||
return <ClockIcon className="w-5 h-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
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">Email Center</h1>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md: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-blue-100 rounded-full flex items-center justify-center">
|
||||
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.total}</p>
|
||||
<p className="text-sm text-gray-500">Total Sent</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-2xl font-bold">{stats.sent}</p>
|
||||
<p className="text-sm text-gray-500">Delivered</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<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">
|
||||
<ClockIcon className="w-5 h-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.pending}</p>
|
||||
<p className="text-sm text-gray-500">Pending</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<XCircleIcon className="w-5 h-5 text-red-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold">{stats.failed}</p>
|
||||
<p className="text-sm text-gray-500">Failed</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-secondary-light-gray mb-6">
|
||||
<nav className="flex gap-6">
|
||||
{(['templates', 'compose', 'logs'] as TabType[]).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={clsx(
|
||||
'py-3 px-1 border-b-2 font-medium text-sm transition-colors relative',
|
||||
{
|
||||
'border-primary-yellow text-primary-dark': activeTab === tab,
|
||||
'border-transparent text-gray-500 hover:text-gray-700': activeTab !== tab,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{tab === 'templates' ? 'Templates' : tab === 'compose' ? 'Compose' : 'Email Logs'}
|
||||
{tab === 'compose' && hasDraft && (
|
||||
<span className="absolute -top-1 -right-2 w-2 h-2 bg-primary-yellow rounded-full" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Templates Tab */}
|
||||
{activeTab === 'templates' && (
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<p className="text-gray-600">Manage email templates for booking confirmations, receipts, and updates.</p>
|
||||
<Button onClick={() => { resetTemplateForm(); setShowTemplateForm(true); }}>
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{templates.map((template) => (
|
||||
<Card key={template.id} className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold text-lg">{template.name}</h3>
|
||||
{template.isSystem && (
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded">System</span>
|
||||
)}
|
||||
{!template.isActive && (
|
||||
<span className="text-xs bg-red-100 text-red-600 px-2 py-0.5 rounded">Inactive</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">{template.slug}</p>
|
||||
<p className="text-sm text-gray-600 mt-2">{template.description || 'No description'}</p>
|
||||
<p className="text-sm font-medium mt-2">Subject: {template.subject}</p>
|
||||
{template.variables && template.variables.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{template.variables.slice(0, 5).map((v: any) => (
|
||||
<span key={v.name} className="text-xs bg-primary-yellow/20 text-primary-dark px-2 py-0.5 rounded">
|
||||
{`{{${v.name}}}`}
|
||||
</span>
|
||||
))}
|
||||
{template.variables.length > 5 && (
|
||||
<span className="text-xs text-gray-500">+{template.variables.length - 5} more</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => handlePreviewTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Preview"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEditTemplate(template)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<PencilIcon className="w-5 h-5" />
|
||||
</button>
|
||||
{!template.isSystem && (
|
||||
<button
|
||||
onClick={() => handleDeleteTemplate(template.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<XCircleIcon className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Compose Tab */}
|
||||
{activeTab === 'compose' && (
|
||||
<div>
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold">Compose Email to Event Attendees</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasDraft && (
|
||||
<span className="text-xs text-gray-500">
|
||||
Draft saved {composeForm.savedAt ? new Date(composeForm.savedAt).toLocaleString() : ''}
|
||||
</span>
|
||||
)}
|
||||
<Button variant="outline" size="sm" onClick={saveDraft}>
|
||||
Save Draft
|
||||
</Button>
|
||||
{hasDraft && (
|
||||
<Button variant="ghost" size="sm" onClick={clearDraft}>
|
||||
Clear Draft
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Event Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Select Event *</label>
|
||||
<select
|
||||
value={composeForm.eventId}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, eventId: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">Choose an event</option>
|
||||
{events.filter(e => e.status === 'published').map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} - {new Date(event.startDatetime).toLocaleDateString()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Recipient Filter */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Recipients</label>
|
||||
<select
|
||||
value={composeForm.recipientFilter}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, recipientFilter: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="all">All attendees</option>
|
||||
<option value="confirmed">Confirmed only</option>
|
||||
<option value="pending">Pending only</option>
|
||||
<option value="checked_in">Checked in only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Template Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Email Template *</label>
|
||||
<select
|
||||
value={composeForm.templateSlug}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, templateSlug: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">Choose a template</option>
|
||||
{templates.filter(t => t.isActive).map((template) => (
|
||||
<option key={template.id} value={template.slug}>
|
||||
{template.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Message */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">
|
||||
Custom Message (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={composeForm.customBody}
|
||||
onChange={(e) => setComposeForm({ ...composeForm, customBody: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
rows={4}
|
||||
placeholder="Add a custom message that will be included in the email..."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
This will be available as {'{{customMessage}}'} in the template
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4 border-t border-secondary-light-gray">
|
||||
<Button
|
||||
onClick={loadRecipientPreview}
|
||||
disabled={!composeForm.eventId}
|
||||
>
|
||||
Preview Recipients
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Recipient Preview Modal */}
|
||||
{showRecipientPreview && (
|
||||
<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-[80vh] overflow-hidden flex flex-col">
|
||||
<div className="p-4 border-b border-secondary-light-gray">
|
||||
<h2 className="text-lg font-bold">Recipient Preview</h2>
|
||||
<p className="text-sm text-gray-500">
|
||||
{previewRecipients.length} recipient(s) will receive this email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
{previewRecipients.length === 0 ? (
|
||||
<p className="text-center text-gray-500 py-8">
|
||||
No recipients match your filter criteria
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{previewRecipients.map((recipient: any) => (
|
||||
<div
|
||||
key={recipient.id}
|
||||
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{recipient.attendeeFirstName} {recipient.attendeeLastName || ''}</p>
|
||||
<p className="text-xs text-gray-500">{recipient.attendeeEmail}</p>
|
||||
</div>
|
||||
<span className={clsx('badge text-xs', {
|
||||
'badge-success': recipient.status === 'confirmed',
|
||||
'badge-warning': recipient.status === 'pending',
|
||||
'badge-info': recipient.status === 'checked_in',
|
||||
'badge-gray': recipient.status === 'cancelled',
|
||||
})}>
|
||||
{recipient.status}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t border-secondary-light-gray flex gap-3">
|
||||
<Button
|
||||
onClick={handleSendEmail}
|
||||
isLoading={sending}
|
||||
disabled={previewRecipients.length === 0}
|
||||
>
|
||||
Send to {previewRecipients.length} Recipients
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowRecipientPreview(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Tab */}
|
||||
{activeTab === 'logs' && (
|
||||
<div>
|
||||
<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">Status</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Recipient</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Subject</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Sent</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{logs.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No emails sent yet
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
logs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(log.status)}
|
||||
<span className="capitalize text-sm">{log.status}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<p className="font-medium text-sm">{log.recipientName || 'Unknown'}</p>
|
||||
<p className="text-sm text-gray-500">{log.recipientEmail}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 max-w-xs">
|
||||
<p className="text-sm truncate">{log.subject}</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(log.sentAt || log.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedLog(log)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="View Email"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{logsTotal > 20 && (
|
||||
<div className="flex items-center justify-between px-6 py-4 border-t border-secondary-light-gray">
|
||||
<p className="text-sm text-gray-600">
|
||||
Showing {logsOffset + 1}-{Math.min(logsOffset + 20, logsTotal)} of {logsTotal}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={logsOffset === 0}
|
||||
onClick={() => setLogsOffset(Math.max(0, logsOffset - 20))}
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={logsOffset + 20 >= logsTotal}
|
||||
onClick={() => setLogsOffset(logsOffset + 20)}
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Template Form Modal */}
|
||||
{showTemplateForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
|
||||
<h2 className="text-xl font-bold mb-6">
|
||||
{editingTemplate ? 'Edit Template' : 'Create Template'}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSaveTemplate} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Template Name"
|
||||
value={templateForm.name}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, name: e.target.value })}
|
||||
required
|
||||
placeholder="e.g., Booking Confirmation"
|
||||
/>
|
||||
<Input
|
||||
label="Slug (unique identifier)"
|
||||
value={templateForm.slug}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, slug: e.target.value })}
|
||||
required
|
||||
disabled={editingTemplate?.isSystem}
|
||||
placeholder="e.g., booking-confirmation"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Subject (English)"
|
||||
value={templateForm.subject}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, subject: e.target.value })}
|
||||
required
|
||||
placeholder="e.g., Your Spanglish ticket is confirmed"
|
||||
/>
|
||||
<Input
|
||||
label="Subject (Spanish)"
|
||||
value={templateForm.subjectEs}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, subjectEs: e.target.value })}
|
||||
placeholder="e.g., Tu entrada de Spanglish está confirmada"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Body HTML (English)</label>
|
||||
<textarea
|
||||
value={templateForm.bodyHtml}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtml: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
|
||||
rows={8}
|
||||
required
|
||||
placeholder="<h2>Your Booking is Confirmed!</h2>..."
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Use {`{{variableName}}`} for dynamic content. Common variables: attendeeName, eventTitle, eventDate, ticketId
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Body HTML (Spanish)</label>
|
||||
<textarea
|
||||
value={templateForm.bodyHtmlEs}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, bodyHtmlEs: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow font-mono text-sm"
|
||||
rows={6}
|
||||
placeholder="<h2>¡Tu Reserva está Confirmada!</h2>..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description</label>
|
||||
<textarea
|
||||
value={templateForm.description}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, description: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={2}
|
||||
placeholder="What is this template used for?"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isActive"
|
||||
checked={templateForm.isActive}
|
||||
onChange={(e) => setTemplateForm({ ...templateForm, isActive: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="isActive" className="text-sm">Template is active</label>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{editingTemplate ? 'Update Template' : 'Create Template'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowTemplateForm(false); resetTemplateForm(); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{previewHtml && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Email Preview</h2>
|
||||
<p className="text-sm text-gray-500">Subject: {previewSubject}</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setPreviewHtml(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
<iframe
|
||||
srcDoc={previewHtml}
|
||||
className="w-full h-full min-h-[500px]"
|
||||
title="Email Preview"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Log Detail Modal */}
|
||||
{selectedLog && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b border-secondary-light-gray">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold">Email Details</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{getStatusIcon(selectedLog.status)}
|
||||
<span className="capitalize text-sm">{selectedLog.status}</span>
|
||||
{selectedLog.errorMessage && (
|
||||
<span className="text-sm text-red-500">- {selectedLog.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => setSelectedLog(null)}>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-4 space-y-2 border-b border-secondary-light-gray bg-gray-50">
|
||||
<p><strong>To:</strong> {selectedLog.recipientName} <{selectedLog.recipientEmail}></p>
|
||||
<p><strong>Subject:</strong> {selectedLog.subject}</p>
|
||||
<p><strong>Sent:</strong> {formatDate(selectedLog.sentAt || selectedLog.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{selectedLog.bodyHtml ? (
|
||||
<iframe
|
||||
srcDoc={selectedLog.bodyHtml}
|
||||
className="w-full h-full min-h-[400px]"
|
||||
title="Email Content"
|
||||
/>
|
||||
) : (
|
||||
<div className="p-4 text-gray-500">Email content not available</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1488
frontend/src/app/admin/events/[id]/page.tsx
Normal file
1488
frontend/src/app/admin/events/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
538
frontend/src/app/admin/events/page.tsx
Normal file
538
frontend/src/app/admin/events/page.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { eventsApi, mediaApi, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, ArrowUpTrayIcon, DocumentDuplicateIcon, ArchiveBoxIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function AdminEventsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<Event | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const [formData, setFormData] = useState<{
|
||||
title: string;
|
||||
titleEs: string;
|
||||
description: string;
|
||||
descriptionEs: string;
|
||||
startDatetime: string;
|
||||
endDatetime: string;
|
||||
location: string;
|
||||
locationUrl: string;
|
||||
price: number;
|
||||
currency: string;
|
||||
capacity: number;
|
||||
status: 'draft' | 'published' | 'cancelled' | 'completed' | 'archived';
|
||||
bannerUrl: string;
|
||||
}>({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
locationUrl: '',
|
||||
price: 0,
|
||||
currency: 'PYG',
|
||||
capacity: 50,
|
||||
status: 'draft',
|
||||
bannerUrl: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadEvents();
|
||||
}, []);
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const { events } = await eventsApi.getAll();
|
||||
setEvents(events);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load events');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
title: '',
|
||||
titleEs: '',
|
||||
description: '',
|
||||
descriptionEs: '',
|
||||
startDatetime: '',
|
||||
endDatetime: '',
|
||||
location: '',
|
||||
locationUrl: '',
|
||||
price: 0,
|
||||
currency: 'PYG',
|
||||
capacity: 50,
|
||||
status: 'draft' as const,
|
||||
bannerUrl: '',
|
||||
});
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
const handleEdit = (event: Event) => {
|
||||
setFormData({
|
||||
title: event.title,
|
||||
titleEs: event.titleEs || '',
|
||||
description: event.description,
|
||||
descriptionEs: event.descriptionEs || '',
|
||||
startDatetime: event.startDatetime.slice(0, 16),
|
||||
endDatetime: event.endDatetime?.slice(0, 16) || '',
|
||||
location: event.location,
|
||||
locationUrl: event.locationUrl || '',
|
||||
price: event.price,
|
||||
currency: event.currency,
|
||||
capacity: event.capacity,
|
||||
status: event.status,
|
||||
bannerUrl: event.bannerUrl || '',
|
||||
});
|
||||
setEditingEvent(event);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const eventData = {
|
||||
title: formData.title,
|
||||
titleEs: formData.titleEs || undefined,
|
||||
description: formData.description,
|
||||
descriptionEs: formData.descriptionEs || undefined,
|
||||
startDatetime: new Date(formData.startDatetime).toISOString(),
|
||||
endDatetime: formData.endDatetime ? new Date(formData.endDatetime).toISOString() : undefined,
|
||||
location: formData.location,
|
||||
locationUrl: formData.locationUrl || undefined,
|
||||
price: formData.price,
|
||||
currency: formData.currency,
|
||||
capacity: formData.capacity,
|
||||
status: formData.status,
|
||||
bannerUrl: formData.bannerUrl || undefined,
|
||||
};
|
||||
|
||||
if (editingEvent) {
|
||||
await eventsApi.update(editingEvent.id, eventData);
|
||||
toast.success('Event updated');
|
||||
} else {
|
||||
await eventsApi.create(eventData);
|
||||
toast.success('Event created');
|
||||
}
|
||||
|
||||
setShowForm(false);
|
||||
resetForm();
|
||||
loadEvents();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save event');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this event?')) return;
|
||||
|
||||
try {
|
||||
await eventsApi.delete(id);
|
||||
toast.success('Event deleted');
|
||||
loadEvents();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete event');
|
||||
}
|
||||
};
|
||||
|
||||
const handleStatusChange = async (event: Event, status: Event['status']) => {
|
||||
try {
|
||||
await eventsApi.update(event.id, { status });
|
||||
toast.success('Status updated');
|
||||
loadEvents();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update status');
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const result = await mediaApi.upload(file, editingEvent?.id, 'event');
|
||||
// Use proxied path so it works through Next.js rewrites
|
||||
setFormData({ ...formData, bannerUrl: result.url });
|
||||
toast.success('Image uploaded successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to upload image');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
draft: 'badge-gray',
|
||||
published: 'badge-success',
|
||||
cancelled: 'badge-danger',
|
||||
completed: 'badge-info',
|
||||
archived: 'badge-gray',
|
||||
};
|
||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{status}</span>;
|
||||
};
|
||||
|
||||
const handleDuplicate = async (event: Event) => {
|
||||
try {
|
||||
await eventsApi.duplicate(event.id);
|
||||
toast.success('Event duplicated successfully');
|
||||
loadEvents();
|
||||
} catch (error) {
|
||||
toast.error('Failed to duplicate event');
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (event: Event) => {
|
||||
try {
|
||||
await eventsApi.update(event.id, { status: 'archived' });
|
||||
toast.success('Event archived');
|
||||
loadEvents();
|
||||
} catch (error) {
|
||||
toast.error('Failed to archive event');
|
||||
}
|
||||
};
|
||||
|
||||
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.events.title')}</h1>
|
||||
<Button onClick={() => { resetForm(); setShowForm(true); }}>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
{t('admin.events.create')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Event Form Modal */}
|
||||
{showForm && (
|
||||
<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">
|
||||
{editingEvent ? t('admin.events.edit') : t('admin.events.create')}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Title (English)"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Title (Spanish)"
|
||||
value={formData.titleEs}
|
||||
onChange={(e) => setFormData({ ...formData, titleEs: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (English)</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Description (Spanish)</label>
|
||||
<textarea
|
||||
value={formData.descriptionEs}
|
||||
onChange={(e) => setFormData({ ...formData, descriptionEs: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
label="Start Date & Time"
|
||||
type="datetime-local"
|
||||
value={formData.startDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, startDatetime: e.target.value })}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="End Date & Time"
|
||||
type="datetime-local"
|
||||
value={formData.endDatetime}
|
||||
onChange={(e) => setFormData({ ...formData, endDatetime: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Location"
|
||||
value={formData.location}
|
||||
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Location URL (Google Maps)"
|
||||
type="url"
|
||||
value={formData.locationUrl}
|
||||
onChange={(e) => setFormData({ ...formData, locationUrl: e.target.value })}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="Price"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.price}
|
||||
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
|
||||
/>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Currency</label>
|
||||
<select
|
||||
value={formData.currency}
|
||||
onChange={(e) => setFormData({ ...formData, currency: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="PYG">PYG</option>
|
||||
<option value="USD">USD</option>
|
||||
</select>
|
||||
</div>
|
||||
<Input
|
||||
label="Capacity"
|
||||
type="number"
|
||||
min="1"
|
||||
value={formData.capacity}
|
||||
onChange={(e) => setFormData({ ...formData, capacity: Number(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Status</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value as any })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Image Upload */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event Banner Image</label>
|
||||
<div className="mt-2">
|
||||
{formData.bannerUrl ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.bannerUrl}
|
||||
alt="Event banner"
|
||||
className="w-full h-40 object-cover rounded-btn"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setFormData({ ...formData, bannerUrl: '' })}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white p-1 rounded-full hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="border-2 border-dashed border-secondary-light-gray rounded-btn p-8 text-center cursor-pointer hover:border-primary-yellow transition-colors"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
<p className="mt-2 text-sm text-gray-500">Uploading...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<PhotoIcon className="w-12 h-12 text-gray-400" />
|
||||
<p className="mt-2 text-sm text-gray-600">Click to upload event image</p>
|
||||
<p className="text-xs text-gray-400">JPEG, PNG, GIF, WebP (max 5MB)</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={saving}>
|
||||
{editingEvent ? 'Update Event' : 'Create Event'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => { setShowForm(false); resetForm(); }}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Events 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">Event</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Date</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Capacity</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">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{events.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No events found. Create your first event!
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<tr key={event.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{event.bannerUrl ? (
|
||||
<img
|
||||
src={event.bannerUrl}
|
||||
alt={event.title}
|
||||
className="w-12 h-12 rounded-lg object-cover flex-shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-lg bg-secondary-gray flex items-center justify-center flex-shrink-0">
|
||||
<PhotoIcon className="w-6 h-6 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium">{event.title}</p>
|
||||
<p className="text-sm text-gray-500 truncate max-w-xs">{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(event.startDatetime)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{event.bookedCount || 0} / {event.capacity}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(event.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{event.status === 'draft' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleStatusChange(event, 'published')}
|
||||
>
|
||||
Publish
|
||||
</Button>
|
||||
)}
|
||||
<Link
|
||||
href={`/admin/events/${event.id}`}
|
||||
className="p-2 hover:bg-primary-yellow/20 text-primary-dark rounded-btn"
|
||||
title="Manage Event"
|
||||
>
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleEdit(event)}
|
||||
className="p-2 hover:bg-gray-100 rounded-btn"
|
||||
title="Edit"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDuplicate(event)}
|
||||
className="p-2 hover:bg-blue-100 text-blue-600 rounded-btn"
|
||||
title="Duplicate"
|
||||
>
|
||||
<DocumentDuplicateIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{event.status !== 'archived' && (
|
||||
<button
|
||||
onClick={() => handleArchive(event)}
|
||||
className="p-2 hover:bg-gray-100 text-gray-600 rounded-btn"
|
||||
title="Archive"
|
||||
>
|
||||
<ArchiveBoxIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(event.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
305
frontend/src/app/admin/gallery/page.tsx
Normal file
305
frontend/src/app/admin/gallery/page.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { mediaApi, Media } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
PhotoIcon,
|
||||
TrashIcon,
|
||||
ArrowUpTrayIcon,
|
||||
XMarkIcon,
|
||||
MagnifyingGlassIcon,
|
||||
LinkIcon,
|
||||
CheckIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function AdminGalleryPage() {
|
||||
const { locale } = useLanguage();
|
||||
const [media, setMedia] = useState<Media[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [selectedImage, setSelectedImage] = useState<Media | null>(null);
|
||||
const [filter, setFilter] = useState<string>('');
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadMedia();
|
||||
}, [filter]);
|
||||
|
||||
const loadMedia = async () => {
|
||||
try {
|
||||
// We need to call the media API - let's add it if it doesn't exist
|
||||
const res = await fetch('/api/media', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('spanglish-token')}`,
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setMedia(data.media || []);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load media');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
setUploading(true);
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
|
||||
for (const file of Array.from(files)) {
|
||||
try {
|
||||
await mediaApi.upload(file, undefined, 'gallery');
|
||||
successCount++;
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (successCount > 0) {
|
||||
toast.success(`${successCount} image(s) uploaded successfully`);
|
||||
}
|
||||
if (failCount > 0) {
|
||||
toast.error(`${failCount} image(s) failed to upload`);
|
||||
}
|
||||
|
||||
loadMedia();
|
||||
setUploading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to delete this image?')) return;
|
||||
|
||||
try {
|
||||
await mediaApi.delete(id);
|
||||
toast.success('Image deleted');
|
||||
loadMedia();
|
||||
if (selectedImage?.id === id) {
|
||||
setSelectedImage(null);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete image');
|
||||
}
|
||||
};
|
||||
|
||||
const copyUrl = async (url: string, id: string) => {
|
||||
const fullUrl = window.location.origin + url;
|
||||
try {
|
||||
await navigator.clipboard.writeText(fullUrl);
|
||||
setCopiedId(id);
|
||||
toast.success('URL copied');
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
} catch (error) {
|
||||
toast.error('Failed to copy URL');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const filteredMedia = media.filter(m => {
|
||||
if (!filter) return true;
|
||||
if (filter === 'gallery') return m.relatedType === 'gallery' || !m.relatedType;
|
||||
if (filter === 'event') return m.relatedType === 'event';
|
||||
return true;
|
||||
});
|
||||
|
||||
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">Gallery Management</h1>
|
||||
<Button onClick={() => fileInputRef.current?.click()} isLoading={uploading}>
|
||||
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
||||
Upload Images
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp,image/avif"
|
||||
multiple
|
||||
onChange={handleUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-3xl font-bold text-primary-dark">{media.length}</p>
|
||||
<p className="text-sm text-gray-500">Total Images</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-3xl font-bold text-blue-600">
|
||||
{media.filter(m => m.relatedType === 'event').length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Event Images</p>
|
||||
</Card>
|
||||
<Card className="p-4 text-center">
|
||||
<p className="text-3xl font-bold text-green-600">
|
||||
{media.filter(m => m.relatedType === 'gallery' || !m.relatedType).length}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Gallery Images</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filter */}
|
||||
<Card className="p-4 mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="text-sm font-medium">Filter:</label>
|
||||
<select
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="">All Images</option>
|
||||
<option value="gallery">Gallery Only</option>
|
||||
<option value="event">Event Banners</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gallery Grid */}
|
||||
{filteredMedia.length === 0 ? (
|
||||
<Card className="p-12 text-center">
|
||||
<PhotoIcon className="w-16 h-16 mx-auto text-gray-300 mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-600 mb-2">No images yet</h3>
|
||||
<p className="text-gray-500 mb-4">Upload images to build your gallery</p>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
<ArrowUpTrayIcon className="w-5 h-5 mr-2" />
|
||||
Upload First Image
|
||||
</Button>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
|
||||
{filteredMedia.map((item) => (
|
||||
<Card key={item.id} className="group relative overflow-hidden aspect-square">
|
||||
<img
|
||||
src={item.fileUrl}
|
||||
alt=""
|
||||
className="w-full h-full object-cover cursor-pointer hover:scale-105 transition-transform"
|
||||
onClick={() => setSelectedImage(item)}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
|
||||
<button
|
||||
onClick={() => setSelectedImage(item)}
|
||||
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
||||
title="View"
|
||||
>
|
||||
<MagnifyingGlassIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => copyUrl(item.fileUrl, item.id)}
|
||||
className="p-2 bg-white rounded-full hover:bg-gray-100"
|
||||
title="Copy URL"
|
||||
>
|
||||
{copiedId === item.id ? (
|
||||
<CheckIcon className="w-5 h-5 text-green-600" />
|
||||
) : (
|
||||
<LinkIcon className="w-5 h-5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(item.id)}
|
||||
className="p-2 bg-white rounded-full hover:bg-red-100 text-red-600"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
{item.relatedType && (
|
||||
<div className="absolute top-2 left-2">
|
||||
<span className={`text-xs px-2 py-1 rounded ${
|
||||
item.relatedType === 'event' ? 'bg-blue-500 text-white' : 'bg-green-500 text-white'
|
||||
}`}>
|
||||
{item.relatedType === 'event' ? 'Event' : 'Gallery'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
{selectedImage && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center p-4"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300"
|
||||
onClick={() => setSelectedImage(null)}
|
||||
>
|
||||
<XMarkIcon className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="max-w-4xl max-h-[80vh] flex flex-col items-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<img
|
||||
src={selectedImage.fileUrl}
|
||||
alt=""
|
||||
className="max-w-full max-h-[70vh] object-contain rounded-lg"
|
||||
/>
|
||||
<div className="mt-4 bg-white rounded-lg p-4 w-full max-w-md">
|
||||
<p className="text-sm text-gray-500 mb-2">
|
||||
Uploaded: {formatDate(selectedImage.createdAt)}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={window.location.origin + selectedImage.fileUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm border rounded-btn bg-gray-50"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => copyUrl(selectedImage.fileUrl, selectedImage.id)}
|
||||
>
|
||||
{copiedId === selectedImage.id ? 'Copied!' : 'Copy'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
onClick={() => handleDelete(selectedImage.id)}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
182
frontend/src/app/admin/layout.tsx
Normal file
182
frontend/src/app/admin/layout.tsx
Normal file
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import LanguageToggle from '@/components/LanguageToggle';
|
||||
import Button from '@/components/ui/Button';
|
||||
import {
|
||||
HomeIcon,
|
||||
CalendarIcon,
|
||||
TicketIcon,
|
||||
UsersIcon,
|
||||
CreditCardIcon,
|
||||
EnvelopeIcon,
|
||||
InboxIcon,
|
||||
PhotoIcon,
|
||||
Cog6ToothIcon,
|
||||
ArrowLeftOnRectangleIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
BanknotesIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { t, locale } = useLanguage();
|
||||
const { user, isAdmin, isLoading, logout } = useAuth();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && (!user || !isAdmin)) {
|
||||
router.push('/login');
|
||||
}
|
||||
}, [user, isAdmin, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user || !isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const navigation = [
|
||||
{ name: t('admin.nav.dashboard'), href: '/admin', icon: HomeIcon },
|
||||
{ name: t('admin.nav.events'), href: '/admin/events', icon: CalendarIcon },
|
||||
{ name: t('admin.nav.bookings'), href: '/admin/bookings', icon: TicketIcon },
|
||||
{ name: t('admin.nav.users'), href: '/admin/users', icon: UsersIcon },
|
||||
{ name: t('admin.nav.payments'), href: '/admin/payments', icon: CreditCardIcon },
|
||||
{ name: locale === 'es' ? 'Opciones de Pago' : 'Payment Options', href: '/admin/payment-options', icon: BanknotesIcon },
|
||||
{ name: t('admin.nav.contacts'), href: '/admin/contacts', icon: EnvelopeIcon },
|
||||
{ name: t('admin.nav.emails'), href: '/admin/emails', icon: InboxIcon },
|
||||
{ name: t('admin.nav.gallery'), href: '/admin/gallery', icon: PhotoIcon },
|
||||
];
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
router.push('/');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-secondary-gray">
|
||||
{/* Mobile sidebar backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={clsx(
|
||||
'fixed top-0 left-0 z-50 h-full w-64 bg-white shadow-lg transform transition-transform duration-300 lg:transform-none',
|
||||
{
|
||||
'translate-x-0': sidebarOpen,
|
||||
'-translate-x-full lg:translate-x-0': !sidebarOpen,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Logo */}
|
||||
<div className="p-6 border-b border-secondary-light-gray">
|
||||
<Link href="/" className="flex items-center gap-2">
|
||||
<span className="text-xl font-bold font-heading text-primary-dark">
|
||||
Span<span className="text-primary-yellow">glish</span>
|
||||
</span>
|
||||
</Link>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('admin.nav.dashboard')}</p>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={clsx(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-btn transition-colors',
|
||||
{
|
||||
'bg-primary-yellow text-primary-dark font-medium': isActive,
|
||||
'text-gray-700 hover:bg-gray-100': !isActive,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<item.icon className="w-5 h-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* User section */}
|
||||
<div className="p-4 border-t border-secondary-light-gray">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||
<span className="font-semibold text-primary-dark">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{user.name}</p>
|
||||
<p className="text-xs text-gray-500 capitalize">{user.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex items-center gap-2 w-full px-4 py-2 text-sm text-gray-700 hover:bg-gray-100 rounded-btn transition-colors"
|
||||
>
|
||||
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
|
||||
{t('nav.logout')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="lg:pl-64">
|
||||
{/* Top bar */}
|
||||
<header className="sticky top-0 z-30 bg-white shadow-sm">
|
||||
<div className="flex items-center justify-between px-4 py-4">
|
||||
<button
|
||||
className="lg:hidden p-2 rounded-btn hover:bg-gray-100"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Bars3Icon className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-4 ml-auto">
|
||||
<LanguageToggle />
|
||||
<Link href="/">
|
||||
<Button variant="outline" size="sm">
|
||||
View Site
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="p-6">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
frontend/src/app/admin/page.tsx
Normal file
246
frontend/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { adminApi, DashboardData } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import {
|
||||
UsersIcon,
|
||||
CalendarIcon,
|
||||
TicketIcon,
|
||||
CurrencyDollarIcon,
|
||||
EnvelopeIcon,
|
||||
UserGroupIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const { user } = useAuth();
|
||||
const [data, setData] = useState<DashboardData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
adminApi.getDashboard()
|
||||
.then(({ dashboard }) => setData(dashboard))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
const statCards = data ? [
|
||||
{
|
||||
label: t('admin.dashboard.stats.users'),
|
||||
value: data.stats.totalUsers,
|
||||
icon: UsersIcon,
|
||||
color: 'bg-blue-100 text-blue-600',
|
||||
href: '/admin/users',
|
||||
},
|
||||
{
|
||||
label: t('admin.dashboard.stats.events'),
|
||||
value: data.stats.totalEvents,
|
||||
icon: CalendarIcon,
|
||||
color: 'bg-purple-100 text-purple-600',
|
||||
href: '/admin/events',
|
||||
},
|
||||
{
|
||||
label: t('admin.dashboard.stats.tickets'),
|
||||
value: data.stats.confirmedTickets,
|
||||
icon: TicketIcon,
|
||||
color: 'bg-green-100 text-green-600',
|
||||
href: '/admin/tickets',
|
||||
},
|
||||
{
|
||||
label: t('admin.dashboard.stats.revenue'),
|
||||
value: `${data.stats.totalRevenue.toLocaleString()} PYG`,
|
||||
icon: CurrencyDollarIcon,
|
||||
color: 'bg-yellow-100 text-yellow-600',
|
||||
href: '/admin/payments',
|
||||
},
|
||||
] : [];
|
||||
|
||||
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="mb-8">
|
||||
<h1 className="text-2xl font-bold text-primary-dark">{t('admin.dashboard.title')}</h1>
|
||||
<p className="text-gray-600">
|
||||
{t('admin.dashboard.welcome')}, {user?.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{statCards.map((stat) => (
|
||||
<Link key={stat.label} href={stat.href}>
|
||||
<Card className="p-6 hover:shadow-card-hover transition-shadow">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500">{stat.label}</p>
|
||||
<p className="mt-2 text-2xl font-bold text-primary-dark">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`w-12 h-12 rounded-btn flex items-center justify-center ${stat.color}`}>
|
||||
<stat.icon className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Alerts */}
|
||||
<Card className="p-6">
|
||||
<h2 className="font-semibold text-lg mb-4">Alerts</h2>
|
||||
<div className="space-y-3">
|
||||
{/* Low capacity warnings */}
|
||||
{data?.upcomingEvents
|
||||
.filter(event => {
|
||||
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
||||
const percentFull = ((event.bookedCount || 0) / event.capacity) * 100;
|
||||
return percentFull >= 80 && availableSeats > 0;
|
||||
})
|
||||
.map(event => {
|
||||
const availableSeats = event.availableSeats ?? (event.capacity - (event.bookedCount || 0));
|
||||
const percentFull = Math.round(((event.bookedCount || 0) / event.capacity) * 100);
|
||||
return (
|
||||
<Link
|
||||
key={event.id}
|
||||
href="/admin/events"
|
||||
className="flex items-center justify-between p-3 bg-orange-50 rounded-btn hover:bg-orange-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-orange-600" />
|
||||
<div>
|
||||
<span className="text-sm font-medium">{event.title}</span>
|
||||
<p className="text-xs text-gray-500">Only {availableSeats} spots left ({percentFull}% full)</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-warning">Low capacity</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Sold out events */}
|
||||
{data?.upcomingEvents
|
||||
.filter(event => (event.availableSeats ?? (event.capacity - (event.bookedCount || 0))) === 0)
|
||||
.map(event => (
|
||||
<Link
|
||||
key={event.id}
|
||||
href="/admin/events"
|
||||
className="flex items-center justify-between p-3 bg-red-50 rounded-btn hover:bg-red-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-red-600" />
|
||||
<div>
|
||||
<span className="text-sm font-medium">{event.title}</span>
|
||||
<p className="text-xs text-gray-500">Event is sold out!</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="badge badge-danger">Sold out</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{data && data.stats.pendingPayments > 0 && (
|
||||
<Link
|
||||
href="/admin/payments"
|
||||
className="flex items-center justify-between p-3 bg-yellow-50 rounded-btn hover:bg-yellow-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CurrencyDollarIcon className="w-5 h-5 text-yellow-600" />
|
||||
<span className="text-sm">Pending payments</span>
|
||||
</div>
|
||||
<span className="badge badge-warning">{data.stats.pendingPayments}</span>
|
||||
</Link>
|
||||
)}
|
||||
{data && data.stats.newContacts > 0 && (
|
||||
<Link
|
||||
href="/admin/contacts"
|
||||
className="flex items-center justify-between p-3 bg-blue-50 rounded-btn hover:bg-blue-100 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<EnvelopeIcon className="w-5 h-5 text-blue-600" />
|
||||
<span className="text-sm">New messages</span>
|
||||
</div>
|
||||
<span className="badge badge-info">{data.stats.newContacts}</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* No alerts */}
|
||||
{data &&
|
||||
data.stats.pendingPayments === 0 &&
|
||||
data.stats.newContacts === 0 &&
|
||||
!data.upcomingEvents.some(e => ((e.bookedCount || 0) / e.capacity) >= 0.8) && (
|
||||
<p className="text-gray-500 text-sm text-center py-2">No alerts at this time</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<Card className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="font-semibold text-lg">Upcoming Events</h2>
|
||||
<Link href="/admin/events" className="text-sm text-secondary-blue hover:underline">
|
||||
{t('common.viewAll')}
|
||||
</Link>
|
||||
</div>
|
||||
{data?.upcomingEvents.length === 0 ? (
|
||||
<p className="text-gray-500 text-sm">No upcoming events</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{data?.upcomingEvents.slice(0, 5).map((event) => (
|
||||
<Link
|
||||
key={event.id}
|
||||
href={`/admin/events`}
|
||||
className="flex items-center justify-between p-3 bg-secondary-gray rounded-btn hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{event.title}</p>
|
||||
<p className="text-xs text-gray-500">{formatDate(event.startDatetime)}</p>
|
||||
</div>
|
||||
<span className="text-sm text-gray-600">
|
||||
{event.bookedCount || 0}/{event.capacity}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-6">
|
||||
<h2 className="font-semibold text-lg mb-4">Quick Stats</h2>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="text-center p-4 bg-secondary-gray rounded-btn">
|
||||
<UserGroupIcon className="w-8 h-8 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-2xl font-bold">{data?.stats.totalSubscribers || 0}</p>
|
||||
<p className="text-xs text-gray-500">Subscribers</p>
|
||||
</div>
|
||||
<div className="text-center p-4 bg-secondary-gray rounded-btn">
|
||||
<TicketIcon className="w-8 h-8 mx-auto text-gray-400" />
|
||||
<p className="mt-2 text-2xl font-bold">{data?.stats.totalTickets || 0}</p>
|
||||
<p className="text-xs text-gray-500">Total Bookings</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
461
frontend/src/app/admin/payment-options/page.tsx
Normal file
461
frontend/src/app/admin/payment-options/page.tsx
Normal file
@@ -0,0 +1,461 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { paymentOptionsApi, PaymentOptionsConfig } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import {
|
||||
CreditCardIcon,
|
||||
BanknotesIcon,
|
||||
BoltIcon,
|
||||
BuildingLibraryIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ArrowPathIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function PaymentOptionsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [options, setOptions] = useState<PaymentOptionsConfig>({
|
||||
tpagoEnabled: false,
|
||||
tpagoLink: null,
|
||||
tpagoInstructions: null,
|
||||
tpagoInstructionsEs: null,
|
||||
bankTransferEnabled: false,
|
||||
bankName: null,
|
||||
bankAccountHolder: null,
|
||||
bankAccountNumber: null,
|
||||
bankAlias: null,
|
||||
bankPhone: null,
|
||||
bankNotes: null,
|
||||
bankNotesEs: null,
|
||||
lightningEnabled: true,
|
||||
cashEnabled: true,
|
||||
cashInstructions: null,
|
||||
cashInstructionsEs: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadOptions();
|
||||
}, []);
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const { paymentOptions } = await paymentOptionsApi.getGlobal();
|
||||
setOptions(paymentOptions);
|
||||
} catch (error) {
|
||||
console.error('Failed to load payment options:', error);
|
||||
toast.error('Failed to load payment options');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
setSaving(true);
|
||||
await paymentOptionsApi.updateGlobal(options);
|
||||
toast.success('Payment options saved successfully');
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to save payment options');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateOption = <K extends keyof PaymentOptionsConfig>(
|
||||
key: K,
|
||||
value: PaymentOptionsConfig[K]
|
||||
) => {
|
||||
setOptions((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-primary-dark">
|
||||
{locale === 'es' ? 'Opciones de Pago' : 'Payment Options'}
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
{locale === 'es'
|
||||
? 'Configura los métodos de pago disponibles para todos los eventos'
|
||||
: 'Configure payment methods available for all events'}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleSave} isLoading={saving}>
|
||||
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* TPago / International Card */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
|
||||
<CreditCardIcon className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'TPago / Tarjeta Internacional' : 'TPago / International Card'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('tpagoEnabled', !options.tpagoEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
options.tpagoEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
options.tpagoEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.tpagoEnabled && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Enlace de Pago TPago' : 'TPago Payment Link'}
|
||||
</label>
|
||||
<Input
|
||||
value={options.tpagoLink || ''}
|
||||
onChange={(e) => updateOption('tpagoLink', e.target.value || null)}
|
||||
placeholder="https://www.tpago.com.py/links?alias=..."
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instructions (English)
|
||||
</label>
|
||||
<textarea
|
||||
value={options.tpagoInstructions || ''}
|
||||
onChange={(e) => updateOption('tpagoInstructions', e.target.value || null)}
|
||||
rows={3}
|
||||
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="Instructions for users..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instrucciones (Español)
|
||||
</label>
|
||||
<textarea
|
||||
value={options.tpagoInstructionsEs || ''}
|
||||
onChange={(e) => updateOption('tpagoInstructionsEs', e.target.value || null)}
|
||||
rows={3}
|
||||
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="Instrucciones para usuarios..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bank Transfer */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<BuildingLibraryIcon className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Transferencia Bancaria' : 'Bank Transfer'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('bankTransferEnabled', !options.bankTransferEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
options.bankTransferEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
options.bankTransferEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.bankTransferEnabled && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Nombre del Banco' : 'Bank Name'}
|
||||
</label>
|
||||
<Input
|
||||
value={options.bankName || ''}
|
||||
onChange={(e) => updateOption('bankName', e.target.value || null)}
|
||||
placeholder="e.g., Banco Itaú"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Titular de la Cuenta' : 'Account Holder'}
|
||||
</label>
|
||||
<Input
|
||||
value={options.bankAccountHolder || ''}
|
||||
onChange={(e) => updateOption('bankAccountHolder', e.target.value || null)}
|
||||
placeholder="e.g., Juan Pérez"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Número de Cuenta' : 'Account Number'}
|
||||
</label>
|
||||
<Input
|
||||
value={options.bankAccountNumber || ''}
|
||||
onChange={(e) => updateOption('bankAccountNumber', e.target.value || null)}
|
||||
placeholder="e.g., 1234567890"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Alias
|
||||
</label>
|
||||
<Input
|
||||
value={options.bankAlias || ''}
|
||||
onChange={(e) => updateOption('bankAlias', e.target.value || null)}
|
||||
placeholder="e.g., spanglish.pagos"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{locale === 'es' ? 'Teléfono' : 'Phone Number'}
|
||||
</label>
|
||||
<Input
|
||||
value={options.bankPhone || ''}
|
||||
onChange={(e) => updateOption('bankPhone', e.target.value || null)}
|
||||
placeholder="e.g., +595 981 123456"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Additional Notes (English)
|
||||
</label>
|
||||
<textarea
|
||||
value={options.bankNotes || ''}
|
||||
onChange={(e) => updateOption('bankNotes', e.target.value || null)}
|
||||
rows={3}
|
||||
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="Additional notes for users..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notas Adicionales (Español)
|
||||
</label>
|
||||
<textarea
|
||||
value={options.bankNotesEs || ''}
|
||||
onChange={(e) => updateOption('bankNotesEs', e.target.value || null)}
|
||||
rows={3}
|
||||
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="Notas adicionales para usuarios..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bitcoin Lightning */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<BoltIcon className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">Bitcoin Lightning</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es' ? 'Pago instantáneo - confirmación automática' : 'Instant payment - automatic confirmation'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('lightningEnabled', !options.lightningEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
options.lightningEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
options.lightningEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.lightningEnabled && (
|
||||
<div className="pt-4 border-t">
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||
<p className="text-sm text-orange-800">
|
||||
{locale === 'es'
|
||||
? 'Lightning está configurado a través de las variables de entorno de LNbits. Verifica que LNBITS_URL y LNBITS_API_KEY estén configurados correctamente.'
|
||||
: 'Lightning is configured via LNbits environment variables. Make sure LNBITS_URL and LNBITS_API_KEY are properly set.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Cash at Door */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<BanknotesIcon className="w-5 h-5 text-yellow-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-lg">
|
||||
{locale === 'es' ? 'Efectivo en el Evento' : 'Cash at the Door'}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
{locale === 'es' ? 'Pago manual - requiere aprobación' : 'Manual payment - requires approval'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => updateOption('cashEnabled', !options.cashEnabled)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
options.cashEnabled ? 'bg-primary-yellow' : 'bg-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
options.cashEnabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{options.cashEnabled && (
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instructions (English)
|
||||
</label>
|
||||
<textarea
|
||||
value={options.cashInstructions || ''}
|
||||
onChange={(e) => updateOption('cashInstructions', e.target.value || null)}
|
||||
rows={3}
|
||||
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="Instructions for cash payments..."
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Instrucciones (Español)
|
||||
</label>
|
||||
<textarea
|
||||
value={options.cashInstructionsEs || ''}
|
||||
onChange={(e) => updateOption('cashInstructionsEs', e.target.value || null)}
|
||||
rows={3}
|
||||
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="Instrucciones para pagos en efectivo..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Summary */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="font-semibold text-lg mb-4">
|
||||
{locale === 'es' ? 'Resumen de Métodos Activos' : 'Active Methods Summary'}
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{options.tpagoEnabled ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||
)}
|
||||
<span className={options.tpagoEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||
TPago
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{options.bankTransferEnabled ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||
)}
|
||||
<span className={options.bankTransferEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{locale === 'es' ? 'Transferencia' : 'Bank Transfer'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{options.lightningEnabled ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||
)}
|
||||
<span className={options.lightningEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||
Lightning
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{options.cashEnabled ? (
|
||||
<CheckCircleIcon className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<XCircleIcon className="w-5 h-5 text-gray-300" />
|
||||
)}
|
||||
<span className={options.cashEnabled ? 'text-gray-900' : 'text-gray-400'}>
|
||||
{locale === 'es' ? 'Efectivo' : 'Cash'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Save button (bottom) */}
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button onClick={handleSave} isLoading={saving} size="lg">
|
||||
{locale === 'es' ? 'Guardar Cambios' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
744
frontend/src/app/admin/payments/page.tsx
Normal file
744
frontend/src/app/admin/payments/page.tsx
Normal file
@@ -0,0 +1,744 @@
|
||||
'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,
|
||||
} 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);
|
||||
|
||||
// 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);
|
||||
toast.success(locale === 'es' ? 'Pago aprobado' : 'Payment approved');
|
||||
setSelectedPayment(null);
|
||||
setNoteText('');
|
||||
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);
|
||||
toast.success(locale === 'es' ? 'Pago rechazado' : 'Payment rejected');
|
||||
setSelectedPayment(null);
|
||||
setNoteText('');
|
||||
loadData();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to reject payment');
|
||||
} finally {
|
||||
setProcessing(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',
|
||||
});
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
const totalPending = payments
|
||||
.filter(p => p.status === 'pending' || p.status === 'pending_approval')
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
const totalPaid = payments
|
||||
.filter(p => p.status === 'paid')
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
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 && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg 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' : 'Amount'}</p>
|
||||
<p className="font-bold text-lg">{formatCurrency(selectedPayment.amount, selectedPayment.currency)}</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<button
|
||||
onClick={() => { setSelectedPayment(null); setNoteText(''); }}
|
||||
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">{pendingApprovalPayments.length}</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>
|
||||
</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>
|
||||
</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 Pagos' : 'Total Payments'}</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) => (
|
||||
<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(payment.amount, payment.currency)}</p>
|
||||
{getStatusBadge(payment.status)}
|
||||
</div>
|
||||
{payment.ticket && (
|
||||
<p className="text-sm font-medium">
|
||||
{payment.ticket.attendeeFirstName} {payment.ticket.attendeeLastName}
|
||||
</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>
|
||||
</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) => (
|
||||
<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>
|
||||
</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 font-medium">
|
||||
{formatCurrency(payment.amount, payment.currency)}
|
||||
</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">
|
||||
{getStatusBadge(payment.status)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
407
frontend/src/app/admin/tickets/page.tsx
Normal file
407
frontend/src/app/admin/tickets/page.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { ticketsApi, eventsApi, Ticket, Event } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { CheckCircleIcon, XCircleIcon, PlusIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export default function AdminTicketsPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [tickets, setTickets] = useState<Ticket[]>([]);
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('');
|
||||
|
||||
// Manual ticket creation state
|
||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createForm, setCreateForm] = useState({
|
||||
eventId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: 'en' as 'en' | 'es',
|
||||
autoCheckin: false,
|
||||
adminNote: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
ticketsApi.getAll(),
|
||||
eventsApi.getAll(),
|
||||
])
|
||||
.then(([ticketsRes, eventsRes]) => {
|
||||
setTickets(ticketsRes.tickets);
|
||||
setEvents(eventsRes.events);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const loadTickets = async () => {
|
||||
try {
|
||||
const params: any = {};
|
||||
if (selectedEvent) params.eventId = selectedEvent;
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
const { tickets } = await ticketsApi.getAll(params);
|
||||
setTickets(tickets);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load tickets');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
loadTickets();
|
||||
}
|
||||
}, [selectedEvent, statusFilter]);
|
||||
|
||||
const handleCheckin = async (id: string) => {
|
||||
try {
|
||||
await ticketsApi.checkin(id);
|
||||
toast.success('Check-in successful');
|
||||
loadTickets();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Check-in failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
if (!confirm('Are you sure you want to cancel this ticket?')) return;
|
||||
|
||||
try {
|
||||
await ticketsApi.cancel(id);
|
||||
toast.success('Ticket cancelled');
|
||||
loadTickets();
|
||||
} catch (error) {
|
||||
toast.error('Failed to cancel ticket');
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async (id: string) => {
|
||||
try {
|
||||
await ticketsApi.updateStatus(id, 'confirmed');
|
||||
toast.success('Ticket confirmed');
|
||||
loadTickets();
|
||||
} catch (error) {
|
||||
toast.error('Failed to confirm ticket');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateTicket = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!createForm.eventId) {
|
||||
toast.error('Please select an event');
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
try {
|
||||
await ticketsApi.adminCreate({
|
||||
eventId: createForm.eventId,
|
||||
firstName: createForm.firstName,
|
||||
lastName: createForm.lastName || undefined,
|
||||
email: createForm.email,
|
||||
phone: createForm.phone,
|
||||
preferredLanguage: createForm.preferredLanguage,
|
||||
autoCheckin: createForm.autoCheckin,
|
||||
adminNote: createForm.adminNote || undefined,
|
||||
});
|
||||
toast.success('Ticket created successfully');
|
||||
setShowCreateForm(false);
|
||||
setCreateForm({
|
||||
eventId: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
preferredLanguage: 'en',
|
||||
autoCheckin: false,
|
||||
adminNote: '',
|
||||
});
|
||||
loadTickets();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to create ticket');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
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',
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'badge-warning',
|
||||
confirmed: 'badge-success',
|
||||
cancelled: 'badge-danger',
|
||||
checked_in: 'badge-info',
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
pending: t('admin.tickets.status.pending'),
|
||||
confirmed: t('admin.tickets.status.confirmed'),
|
||||
cancelled: t('admin.tickets.status.cancelled'),
|
||||
checked_in: t('admin.tickets.status.checkedIn'),
|
||||
};
|
||||
return <span className={`badge ${styles[status] || 'badge-gray'}`}>{labels[status] || status}</span>;
|
||||
};
|
||||
|
||||
const getEventName = (eventId: string) => {
|
||||
const event = events.find(e => e.id === eventId);
|
||||
return event?.title || 'Unknown Event';
|
||||
};
|
||||
|
||||
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.tickets.title')}</h1>
|
||||
<Button onClick={() => setShowCreateForm(true)}>
|
||||
<PlusIcon className="w-5 h-5 mr-2" />
|
||||
Create Ticket
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Manual Ticket Creation Modal */}
|
||||
{showCreateForm && (
|
||||
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-lg p-6">
|
||||
<h2 className="text-xl font-bold mb-6">Create Ticket Manually</h2>
|
||||
<form onSubmit={handleCreateTicket} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event *</label>
|
||||
<select
|
||||
value={createForm.eventId}
|
||||
onChange={(e) => setCreateForm({ ...createForm, eventId: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
required
|
||||
>
|
||||
<option value="">Select an event</option>
|
||||
{events.filter(e => e.status === 'published').map((event) => (
|
||||
<option key={event.id} value={event.id}>
|
||||
{event.title} ({event.availableSeats} spots left)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Input
|
||||
label="First Name *"
|
||||
value={createForm.firstName}
|
||||
onChange={(e) => setCreateForm({ ...createForm, firstName: e.target.value })}
|
||||
required
|
||||
placeholder="First name"
|
||||
/>
|
||||
<Input
|
||||
label="Last Name (optional)"
|
||||
value={createForm.lastName}
|
||||
onChange={(e) => setCreateForm({ ...createForm, lastName: e.target.value })}
|
||||
placeholder="Last name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Email (optional)"
|
||||
type="email"
|
||||
value={createForm.email}
|
||||
onChange={(e) => setCreateForm({ ...createForm, email: e.target.value })}
|
||||
placeholder="attendee@email.com"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Phone (optional)"
|
||||
value={createForm.phone}
|
||||
onChange={(e) => setCreateForm({ ...createForm, phone: e.target.value })}
|
||||
placeholder="+595 XXX XXX XXX"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Preferred Language</label>
|
||||
<select
|
||||
value={createForm.preferredLanguage}
|
||||
onChange={(e) => setCreateForm({ ...createForm, preferredLanguage: e.target.value as 'en' | 'es' })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Spanish</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Admin Note</label>
|
||||
<textarea
|
||||
value={createForm.adminNote}
|
||||
onChange={(e) => setCreateForm({ ...createForm, adminNote: e.target.value })}
|
||||
className="w-full px-4 py-3 rounded-btn border border-secondary-light-gray"
|
||||
rows={2}
|
||||
placeholder="Internal note about this booking (optional)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoCheckin"
|
||||
checked={createForm.autoCheckin}
|
||||
onChange={(e) => setCreateForm({ ...createForm, autoCheckin: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="autoCheckin" className="text-sm">
|
||||
Automatically check in (mark as present)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-btn p-3 text-sm text-yellow-800">
|
||||
Note: This creates a ticket with cash payment marked as paid. Use this for walk-ins at the door. Email and phone are optional for door entries.
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button type="submit" isLoading={creating}>
|
||||
Create Ticket
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setShowCreateForm(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Event</label>
|
||||
<select
|
||||
value={selectedEvent}
|
||||
onChange={(e) => setSelectedEvent(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[200px]"
|
||||
>
|
||||
<option value="">All Events</option>
|
||||
{events.map((event) => (
|
||||
<option key={event.id} value={event.id}>{event.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</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]"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="confirmed">Confirmed</option>
|
||||
<option value="checked_in">Checked In</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Tickets 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">Ticket</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Event</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Booked</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">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{tickets.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No tickets found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
tickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="font-mono text-sm font-medium">{ticket.qrCode}</p>
|
||||
<p className="text-xs text-gray-500">ID: {ticket.id.slice(0, 8)}...</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm">
|
||||
{getEventName(ticket.eventId)}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(ticket.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
{getStatusBadge(ticket.status)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{ticket.status === 'pending' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleConfirm(ticket.id)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status === 'confirmed' && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleCheckin(ticket.id)}
|
||||
>
|
||||
<CheckCircleIcon className="w-4 h-4 mr-1" />
|
||||
{t('admin.tickets.checkin')}
|
||||
</Button>
|
||||
)}
|
||||
{ticket.status !== 'cancelled' && ticket.status !== 'checked_in' && (
|
||||
<button
|
||||
onClick={() => handleCancel(ticket.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Cancel"
|
||||
>
|
||||
<XCircleIcon className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
183
frontend/src/app/admin/users/page.tsx
Normal file
183
frontend/src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usersApi, User } from '@/lib/api';
|
||||
import Card from '@/components/ui/Card';
|
||||
import Button from '@/components/ui/Button';
|
||||
import { TrashIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { t, locale } = useLanguage();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [roleFilter, setRoleFilter] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
loadUsers();
|
||||
}, [roleFilter]);
|
||||
|
||||
const loadUsers = async () => {
|
||||
try {
|
||||
const { users } = await usersApi.getAll(roleFilter || undefined);
|
||||
setUsers(users);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleChange = async (userId: string, newRole: string) => {
|
||||
try {
|
||||
await usersApi.update(userId, { role: newRole as any });
|
||||
toast.success('Role updated');
|
||||
loadUsers();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update role');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this user?')) return;
|
||||
|
||||
try {
|
||||
await usersApi.delete(userId);
|
||||
toast.success('User deleted');
|
||||
loadUsers();
|
||||
} catch (error: any) {
|
||||
toast.error(error.message || 'Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString(locale === 'es' ? 'es-ES' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
const styles: Record<string, string> = {
|
||||
admin: 'badge-danger',
|
||||
organizer: 'badge-info',
|
||||
staff: 'badge-warning',
|
||||
marketing: 'badge-success',
|
||||
user: 'badge-gray',
|
||||
};
|
||||
return <span className={`badge ${styles[role] || 'badge-gray'}`}>{t(`admin.users.roles.${role}`)}</span>;
|
||||
};
|
||||
|
||||
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.users.title')}</h1>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="p-4 mb-6">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t('admin.users.role')}</label>
|
||||
<select
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value)}
|
||||
className="px-4 py-2 rounded-btn border border-secondary-light-gray min-w-[150px]"
|
||||
>
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||
<option value="user">{t('admin.users.roles.user')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Users 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">User</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Contact</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Role</th>
|
||||
<th className="text-left px-6 py-3 text-sm font-medium text-gray-600">Joined</th>
|
||||
<th className="text-right px-6 py-3 text-sm font-medium text-gray-600">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-secondary-light-gray">
|
||||
{users.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-6 py-12 text-center text-gray-500">
|
||||
No users found
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-primary-yellow/20 rounded-full flex items-center justify-center">
|
||||
<span className="font-semibold text-primary-dark">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{user.name}</p>
|
||||
<p className="text-sm text-gray-500">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{user.phone || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<select
|
||||
value={user.role}
|
||||
onChange={(e) => handleRoleChange(user.id, e.target.value)}
|
||||
className="px-2 py-1 rounded border border-secondary-light-gray text-sm"
|
||||
>
|
||||
<option value="user">{t('admin.users.roles.user')}</option>
|
||||
<option value="staff">{t('admin.users.roles.staff')}</option>
|
||||
<option value="marketing">{t('admin.users.roles.marketing')}</option>
|
||||
<option value="organizer">{t('admin.users.roles.organizer')}</option>
|
||||
<option value="admin">{t('admin.users.roles.admin')}</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-sm text-gray-600">
|
||||
{formatDate(user.createdAt)}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleDelete(user.id)}
|
||||
className="p-2 hover:bg-red-100 text-red-600 rounded-btn"
|
||||
title="Delete"
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user