Paginate the bookings page and the event attendee/ticket tabs.
Extract the pagination controls shared by the events and users lists into components/admin/Pagination, along with a usePaginatedList helper that slices a list and keeps the page in range when the list shrinks under it. Bookings, the Attendees tab and the Tickets tab paginate client-side rather than on the server: each already loads its full set for figures a slice would break — the bookings stat cards, the group-booking totals and the sibling payment-method lookup, and the per-status counts the two tabs share with the event's other tabs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
87bf9a6151
commit
ed1d3a8c12
@@ -9,26 +9,13 @@ import Button from '@/components/ui/Button';
|
||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||
import Input from '@/components/ui/Input';
|
||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
import Pagination from '@/components/admin/Pagination';
|
||||
|
||||
type RegisteredRange = '' | '7d' | '30d' | '90d';
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||
|
||||
function getPageNumbers(current: number, totalPages: number): (number | '...')[] {
|
||||
if (totalPages <= 7) return Array.from({ length: totalPages }, (_, i) => i + 1);
|
||||
const pages: (number | '...')[] = [1];
|
||||
const start = Math.max(2, current - 1);
|
||||
const end = Math.min(totalPages - 1, current + 1);
|
||||
if (start > 2) pages.push('...');
|
||||
for (let i = start; i <= end; i++) pages.push(i);
|
||||
if (end < totalPages - 1) pages.push('...');
|
||||
pages.push(totalPages);
|
||||
return pages;
|
||||
}
|
||||
|
||||
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
|
||||
if (!range) return undefined;
|
||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
||||
@@ -418,64 +405,14 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 0 && (
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label>
|
||||
<select
|
||||
id="users-page-size"
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={clsx(
|
||||
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
|
||||
p === page
|
||||
? 'bg-primary-yellow text-primary-dark font-semibold'
|
||||
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
|
||||
)}
|
||||
aria-current={p === page ? 'page' : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Pagination
|
||||
id="users"
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={setPageSize}
|
||||
/>
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||
|
||||
Reference in New Issue
Block a user