Files
Spanglish/frontend/src/app/admin/bookings/page.tsx

460 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}