- Extract shared mobile components (BottomSheet, MoreMenu, Dropdown, etc.) into MobileComponents.tsx - Make admin pages mobile-friendly: bookings, emails, events, faq, payments, tickets, users - Redesign homepage Next Event card with banner image, responsive layout, and updated styling - Fix past events showing on homepage/linktree: use proper Date comparison, auto-unfeature expired events - Add "Over" tag to admin events list for past events - Fix backend FRONTEND_URL for cache revalidation Co-authored-by: Cursor <cursoragent@cursor.com>
607 lines
26 KiB
TypeScript
607 lines
26 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 { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
|
import {
|
|
TicketIcon,
|
|
CheckCircleIcon,
|
|
XCircleIcon,
|
|
CurrencyDollarIcon,
|
|
UserIcon,
|
|
EnvelopeIcon,
|
|
PhoneIcon,
|
|
FunnelIcon,
|
|
MagnifyingGlassIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import toast from 'react-hot-toast';
|
|
import clsx from 'clsx';
|
|
|
|
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);
|
|
|
|
const [selectedEvent, setSelectedEvent] = useState<string>('');
|
|
const [selectedStatus, setSelectedStatus] = useState<string>('');
|
|
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, []);
|
|
|
|
const loadData = async () => {
|
|
try {
|
|
const [ticketsRes, eventsRes] = await Promise.all([
|
|
ticketsApi.getAll(),
|
|
eventsApi.getAll(),
|
|
]);
|
|
|
|
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',
|
|
timeZone: 'America/Asuncion',
|
|
});
|
|
};
|
|
|
|
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;
|
|
}
|
|
};
|
|
|
|
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;
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase();
|
|
const name = `${ticket.attendeeFirstName} ${ticket.attendeeLastName || ''}`.toLowerCase();
|
|
return name.includes(q) || (ticket.attendeeEmail?.toLowerCase().includes(q) || false);
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const sortedTickets = [...filteredTickets].sort(
|
|
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
|
);
|
|
|
|
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,
|
|
};
|
|
|
|
const getBookingInfo = (ticket: TicketWithDetails) => {
|
|
if (!ticket.bookingId) {
|
|
return { ticketCount: 1, bookingTotal: Number(ticket.payment?.amount || 0) };
|
|
}
|
|
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),
|
|
};
|
|
};
|
|
|
|
const hasActiveFilters = selectedEvent || selectedStatus || selectedPaymentStatus || searchQuery;
|
|
|
|
const clearFilters = () => {
|
|
setSelectedEvent('');
|
|
setSelectedStatus('');
|
|
setSelectedPaymentStatus('');
|
|
setSearchQuery('');
|
|
};
|
|
|
|
const getPrimaryAction = (ticket: TicketWithDetails) => {
|
|
if (ticket.status === 'pending' && ticket.payment?.status === 'pending') {
|
|
return { label: 'Mark Paid', onClick: () => handleMarkPaid(ticket.id), color: 'text-green-600' };
|
|
}
|
|
if (ticket.status === 'confirmed') {
|
|
return { label: 'Check In', onClick: () => handleCheckin(ticket.id), color: 'text-blue-600' };
|
|
}
|
|
return null;
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin w-8 h-8 border-4 border-primary-yellow border-t-transparent rounded-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h1 className="text-xl md:text-2xl font-bold text-primary-dark">Manage Bookings</h1>
|
|
</div>
|
|
|
|
{/* Stats Cards */}
|
|
<div className="grid grid-cols-3 md:grid-cols-3 lg:grid-cols-6 gap-2 md:gap-4 mb-6">
|
|
<Card className="p-3 md:p-4 text-center">
|
|
<p className="text-xl md:text-2xl font-bold text-primary-dark">{stats.total}</p>
|
|
<p className="text-xs md:text-sm text-gray-500">Total</p>
|
|
</Card>
|
|
<Card className="p-3 md:p-4 text-center border-l-4 border-yellow-400">
|
|
<p className="text-xl md:text-2xl font-bold text-yellow-600">{stats.pending}</p>
|
|
<p className="text-xs md:text-sm text-gray-500">Pending</p>
|
|
</Card>
|
|
<Card className="p-3 md:p-4 text-center border-l-4 border-green-400">
|
|
<p className="text-xl md:text-2xl font-bold text-green-600">{stats.confirmed}</p>
|
|
<p className="text-xs md:text-sm text-gray-500">Confirmed</p>
|
|
</Card>
|
|
<Card className="p-3 md:p-4 text-center border-l-4 border-blue-400">
|
|
<p className="text-xl md:text-2xl font-bold text-blue-600">{stats.checkedIn}</p>
|
|
<p className="text-xs md:text-sm text-gray-500">Checked In</p>
|
|
</Card>
|
|
<Card className="p-3 md:p-4 text-center border-l-4 border-red-400">
|
|
<p className="text-xl md:text-2xl font-bold text-red-600">{stats.cancelled}</p>
|
|
<p className="text-xs md:text-sm text-gray-500">Cancelled</p>
|
|
</Card>
|
|
<Card className="p-3 md:p-4 text-center border-l-4 border-orange-400">
|
|
<p className="text-xl md:text-2xl font-bold text-orange-600">{stats.pendingPayment}</p>
|
|
<p className="text-xs md:text-sm text-gray-500">Pending Pay</p>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Desktop Filters */}
|
|
<Card className="p-4 mb-6 hidden md:block">
|
|
<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-4 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Search</label>
|
|
<div className="relative">
|
|
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Name or email..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-3 py-2 rounded-btn border border-secondary-light-gray text-sm focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<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 text-sm">
|
|
<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 text-sm">
|
|
<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 text-sm">
|
|
<option value="">All Payments</option>
|
|
<option value="pending">Pending</option>
|
|
<option value="paid">Paid</option>
|
|
<option value="refunded">Refunded</option>
|
|
<option value="failed">Failed</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
{hasActiveFilters && (
|
|
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
|
<span>Showing {sortedTickets.length} of {tickets.length}</span>
|
|
<button onClick={clearFilters} className="text-primary-yellow hover:underline">Clear</button>
|
|
</div>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Mobile Toolbar */}
|
|
<div className="md:hidden space-y-2 mb-4">
|
|
<div className="relative">
|
|
<MagnifyingGlassIcon className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search name or email..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-9 pr-3 py-2.5 text-sm rounded-btn border border-secondary-light-gray focus:outline-none focus:ring-2 focus:ring-primary-yellow"
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setMobileFilterOpen(true)}
|
|
className={clsx(
|
|
'flex items-center gap-1.5 px-3 py-2 rounded-btn border text-sm min-h-[44px]',
|
|
hasActiveFilters
|
|
? 'border-primary-yellow bg-yellow-50 text-primary-dark'
|
|
: 'border-secondary-light-gray text-gray-600'
|
|
)}
|
|
>
|
|
<FunnelIcon className="w-4 h-4" />
|
|
Filters
|
|
{hasActiveFilters && <span className="text-xs">({sortedTickets.length})</span>}
|
|
</button>
|
|
{hasActiveFilters && (
|
|
<button onClick={clearFilters} className="text-xs text-primary-yellow ml-auto min-h-[44px] flex items-center">
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Desktop: Table */}
|
|
<Card className="overflow-hidden hidden md:block">
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full">
|
|
<thead className="bg-secondary-gray">
|
|
<tr>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Attendee</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Payment</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
<th className="text-left px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Booked</th>
|
|
<th className="text-right px-4 py-2 text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-secondary-light-gray">
|
|
{sortedTickets.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="px-4 py-12 text-center text-gray-500 text-sm">
|
|
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-4 py-3">
|
|
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
<p className="text-xs text-gray-500 truncate max-w-[200px]">{ticket.attendeeEmail || 'N/A'}</p>
|
|
{ticket.attendeePhone && <p className="text-xs text-gray-400">{ticket.attendeePhone}</p>}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className="text-sm truncate max-w-[150px] block">
|
|
{ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getPaymentStatusColor(ticket.payment?.status || 'pending')}`}>
|
|
{ticket.payment?.status || 'pending'}
|
|
</span>
|
|
<p className="text-xs text-gray-500 mt-0.5">{getPaymentMethodLabel(ticket.payment?.provider || 'cash')}</p>
|
|
{ticket.payment && (
|
|
<p className="text-xs font-medium mt-0.5">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</p>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${getStatusColor(ticket.status)}`}>
|
|
{ticket.status.replace('_', ' ')}
|
|
</span>
|
|
{ticket.bookingId && (
|
|
<p className="text-[10px] text-purple-600 mt-0.5">Group Booking</p>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-xs text-gray-500">
|
|
{formatDate(ticket.createdAt)}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center justify-end gap-1">
|
|
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && (
|
|
<Button size="sm" variant="outline" onClick={() => handleMarkPaid(ticket.id)}
|
|
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
|
Mark Paid
|
|
</Button>
|
|
)}
|
|
{ticket.status === 'confirmed' && (
|
|
<Button size="sm" onClick={() => handleCheckin(ticket.id)}
|
|
isLoading={processing === ticket.id} className="text-xs px-2 py-1">
|
|
Check In
|
|
</Button>
|
|
)}
|
|
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
|
<MoreMenu>
|
|
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
|
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel
|
|
</DropdownItem>
|
|
</MoreMenu>
|
|
)}
|
|
{ticket.status === 'checked_in' && (
|
|
<span className="text-xs text-green-600 flex items-center gap-1">
|
|
<CheckCircleIcon className="w-4 h-4" /> Attended
|
|
</span>
|
|
)}
|
|
{ticket.status === 'cancelled' && (
|
|
<span className="text-xs text-gray-400">Cancelled</span>
|
|
)}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Mobile: Card List */}
|
|
<div className="md:hidden space-y-2">
|
|
{sortedTickets.length === 0 ? (
|
|
<div className="text-center py-10 text-gray-500 text-sm">
|
|
No bookings found.
|
|
</div>
|
|
) : (
|
|
sortedTickets.map((ticket) => {
|
|
const bookingInfo = getBookingInfo(ticket);
|
|
const primary = getPrimaryAction(ticket);
|
|
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
|
|
return (
|
|
<Card key={ticket.id} className="p-3">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<p className="font-medium text-sm truncate">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
|
<p className="text-xs text-gray-500 truncate">{ticket.attendeeEmail || 'N/A'}</p>
|
|
</div>
|
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getStatusColor(ticket.status))}>
|
|
{ticket.status.replace('_', ' ')}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div className="mt-2 flex items-center gap-2 text-xs text-gray-500">
|
|
<span className="truncate">{eventTitle}</span>
|
|
<span className="text-gray-300">|</span>
|
|
<span className={clsx('inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium', getPaymentStatusColor(ticket.payment?.status || 'pending'))}>
|
|
{ticket.payment?.status || 'pending'}
|
|
</span>
|
|
{ticket.payment && (
|
|
<>
|
|
<span className="text-gray-300">|</span>
|
|
<span className="font-medium text-gray-700">{bookingInfo.bookingTotal.toLocaleString()} {ticket.payment.currency}</span>
|
|
</>
|
|
)}
|
|
</div>
|
|
{ticket.bookingId && (
|
|
<p className="text-[10px] text-purple-600 mt-1">{bookingInfo.ticketCount} tickets - Group Booking</p>
|
|
)}
|
|
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
|
|
<p className="text-[10px] text-gray-400">{formatDate(ticket.createdAt)}</p>
|
|
<div className="flex items-center gap-1">
|
|
{primary && (
|
|
<Button size="sm" variant={ticket.status === 'confirmed' ? 'primary' : 'outline'}
|
|
onClick={primary.onClick} isLoading={processing === ticket.id}
|
|
className="text-xs px-2.5 py-1.5 min-h-[36px]">
|
|
{primary.label}
|
|
</Button>
|
|
)}
|
|
{(ticket.status === 'pending' || ticket.status === 'confirmed') && (
|
|
<MoreMenu>
|
|
{ticket.status === 'pending' && ticket.payment?.status === 'pending' && !primary && (
|
|
<DropdownItem onClick={() => handleMarkPaid(ticket.id)}>
|
|
<CurrencyDollarIcon className="w-4 h-4 mr-2" /> Mark Paid
|
|
</DropdownItem>
|
|
)}
|
|
<DropdownItem onClick={() => handleCancel(ticket.id)} className="text-red-600">
|
|
<XCircleIcon className="w-4 h-4 mr-2" /> Cancel Booking
|
|
</DropdownItem>
|
|
</MoreMenu>
|
|
)}
|
|
{ticket.status === 'checked_in' && (
|
|
<span className="text-[10px] text-green-600 flex items-center gap-1">
|
|
<CheckCircleIcon className="w-3.5 h-3.5" /> Attended
|
|
</span>
|
|
)}
|
|
{ticket.status === 'cancelled' && (
|
|
<span className="text-[10px] text-gray-400">Cancelled</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile Filter BottomSheet */}
|
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
|
<div className="space-y-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.5 rounded-btn border border-secondary-light-gray text-sm min-h-[44px]">
|
|
<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>
|
|
<div className="space-y-1">
|
|
{[
|
|
{ value: '', label: 'All Statuses' },
|
|
{ value: 'pending', label: `Pending (${stats.pending})` },
|
|
{ value: 'confirmed', label: `Confirmed (${stats.confirmed})` },
|
|
{ value: 'checked_in', label: `Checked In (${stats.checkedIn})` },
|
|
{ value: 'cancelled', label: `Cancelled (${stats.cancelled})` },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setSelectedStatus(opt.value)}
|
|
className={clsx(
|
|
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
|
selectedStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
|
)}
|
|
>
|
|
{opt.label}
|
|
{selectedStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Payment Status</label>
|
|
<div className="space-y-1">
|
|
{[
|
|
{ value: '', label: 'All Payments' },
|
|
{ value: 'pending', label: 'Pending' },
|
|
{ value: 'paid', label: 'Paid' },
|
|
{ value: 'refunded', label: 'Refunded' },
|
|
{ value: 'failed', label: 'Failed' },
|
|
].map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setSelectedPaymentStatus(opt.value)}
|
|
className={clsx(
|
|
'w-full text-left px-3 py-2.5 rounded-btn text-sm min-h-[44px] flex items-center justify-between',
|
|
selectedPaymentStatus === opt.value ? 'bg-yellow-50 text-primary-dark font-medium' : 'hover:bg-gray-50'
|
|
)}
|
|
>
|
|
{opt.label}
|
|
{selectedPaymentStatus === opt.value && <CheckCircleIcon className="w-4 h-4 text-primary-yellow" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 pt-2">
|
|
<Button variant="outline" onClick={() => { clearFilters(); setMobileFilterOpen(false); }} className="flex-1 min-h-[44px]">
|
|
Clear All
|
|
</Button>
|
|
<Button onClick={() => setMobileFilterOpen(false)} className="flex-1 min-h-[44px]">
|
|
Apply
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</BottomSheet>
|
|
|
|
<AdminMobileStyles />
|
|
</div>
|
|
);
|
|
}
|