460 lines
18 KiB
TypeScript
460 lines
18 KiB
TypeScript
'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'> {
|
||
bookingId?: string;
|
||
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,
|
||
};
|
||
|
||
// Helper to get booking info for a ticket (ticket count and total)
|
||
const getBookingInfo = (ticket: TicketWithDetails) => {
|
||
if (!ticket.bookingId) {
|
||
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
|
||
}
|
||
|
||
// Count all tickets with the same bookingId
|
||
const bookingTickets = tickets.filter(
|
||
t => t.bookingId === ticket.bookingId
|
||
);
|
||
|
||
return {
|
||
ticketCount: bookingTickets.length,
|
||
bookingTotal: bookingTickets.reduce((sum, t) => sum + Number(t.payment?.amount || 0), 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">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) => {
|
||
const bookingInfo = getBookingInfo(ticket);
|
||
return (
|
||
<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 && (
|
||
<div>
|
||
<p className="text-sm font-medium">
|
||
{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}
|
||
</p>
|
||
{bookingInfo.ticketCount > 1 && (
|
||
<p className="text-xs text-purple-600 mt-1">
|
||
📦 {bookingInfo.ticketCount} × {Number(ticket.payment.amount).toLocaleString()} {ticket.payment.currency}
|
||
</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
</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>
|
||
)}
|
||
{ticket.bookingId && (
|
||
<p className="text-xs text-purple-600 mt-1" title="Part of multi-ticket booking">
|
||
📦 Group Booking
|
||
</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>
|
||
);
|
||
}
|