diff --git a/frontend/src/app/admin/bookings/page.tsx b/frontend/src/app/admin/bookings/page.tsx index a1ecda1..0de3de6 100644 --- a/frontend/src/app/admin/bookings/page.tsx +++ b/frontend/src/app/admin/bookings/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { useLanguage } from '@/context/LanguageContext'; import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api'; import { parseDate, formatRucDisplay } from '@/lib/utils'; @@ -8,6 +8,7 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { AdminPageSkeleton } from '@/components/ui/Skeleton'; import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents'; +import Pagination, { usePaginatedList } from '@/components/admin/Pagination'; import { TicketIcon, CheckCircleIcon, @@ -51,6 +52,8 @@ export default function AdminBookingsPage() { const [selectedPaymentStatus, setSelectedPaymentStatus] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [mobileFilterOpen, setMobileFilterOpen] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); useEffect(() => { loadData(); @@ -203,6 +206,19 @@ export default function AdminBookingsPage() { (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() ); + // Bookings are paginated client-side: the page already loads every ticket so + // that the stat cards, the group-booking totals and the sibling payment-method + // lookup can see the whole set, and those would break on a server-side slice. + const filterKey = JSON.stringify([selectedEvent, selectedStatus, selectedPaymentStatus, searchQuery]); + const prevFilterKey = useRef(filterKey); + useEffect(() => { + if (prevFilterKey.current !== filterKey) { + prevFilterKey.current = filterKey; + setPage(1); + } + }, [filterKey]); + const pagedTickets = usePaginatedList(sortedTickets, page, pageSize, setPage); + const stats = { total: tickets.length, pending: tickets.filter(t => t.status === 'pending').length, @@ -408,7 +424,7 @@ export default function AdminBookingsPage() { ) : ( - sortedTickets.map((ticket) => { + pagedTickets.map((ticket) => { const bookingInfo = getBookingInfo(ticket); return ( @@ -502,7 +518,7 @@ export default function AdminBookingsPage() { No bookings found. ) : ( - sortedTickets.map((ticket) => { + pagedTickets.map((ticket) => { const bookingInfo = getBookingInfo(ticket); const primary = getPrimaryAction(ticket); const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown'; @@ -580,6 +596,15 @@ export default function AdminBookingsPage() { )} + + {/* Mobile Filter BottomSheet */} setMobileFilterOpen(false)} title="Filters">
diff --git a/frontend/src/app/admin/events/[id]/_tabs/AttendeesTab.tsx b/frontend/src/app/admin/events/[id]/_tabs/AttendeesTab.tsx index fe038b4..d473868 100644 --- a/frontend/src/app/admin/events/[id]/_tabs/AttendeesTab.tsx +++ b/frontend/src/app/admin/events/[id]/_tabs/AttendeesTab.tsx @@ -1,8 +1,10 @@ +import { useEffect, useRef, useState } from 'react'; import { Ticket } from '@/lib/api'; import { parseDate, EVENT_TIMEZONE } from '@/lib/utils'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents'; +import Pagination, { usePaginatedList } from '@/components/admin/Pagination'; import clsx from 'clsx'; import { MagnifyingGlassIcon, @@ -79,6 +81,20 @@ export function AttendeesTab({ handleMarkPaid, handleCheckin, }: AttendeesTabProps) { + // Paginated client-side: the parent already holds every ticket for the event + // so the status counts and the other tabs keep seeing the full set. + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const filterKey = `${searchQuery}|${statusFilter}`; + const prevFilterKey = useRef(filterKey); + useEffect(() => { + if (prevFilterKey.current !== filterKey) { + prevFilterKey.current = filterKey; + setPage(1); + } + }, [filterKey]); + const pagedTickets = usePaginatedList(filteredTickets, page, pageSize, setPage); + return (
{/* Desktop toolbar */} @@ -237,7 +253,7 @@ export function AttendeesTab({ ) : ( - filteredTickets.map((ticket) => { + pagedTickets.map((ticket) => { const primary = getPrimaryAction(ticket); return ( @@ -323,7 +339,7 @@ export function AttendeesTab({ {tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
) : ( - filteredTickets.map((ticket) => { + pagedTickets.map((ticket) => { const primary = getPrimaryAction(ticket); return ( @@ -380,6 +396,16 @@ export function AttendeesTab({ )}
+ + {/* Mobile FAB */}
- {/* Pagination */} - {total > 0 && ( -
-
- - - - {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} - -
-
- - {getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) => - p === '...' ? ( - - ) : ( - - ) - )} - -
-
- )} + {/* Mobile FAB */}
diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index 6705979..1cb3b13 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -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() { )}
- {/* Pagination */} - {total > 0 && ( -
-
- - - - {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} - -
-
- - {getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) => - p === '...' ? ( - - ) : ( - - ) - )} - -
-
- )} + {/* Mobile Filter BottomSheet */} setMobileFilterOpen(false)} title="Filters"> diff --git a/frontend/src/components/admin/Pagination.tsx b/frontend/src/components/admin/Pagination.tsx new file mode 100644 index 0000000..f9c3785 --- /dev/null +++ b/frontend/src/components/admin/Pagination.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { useEffect } from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'; +import clsx from 'clsx'; + +export const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +/** + * Page buttons to show: first, last, the current page and its neighbours, with + * ellipses standing in for the gaps once there are more than 7 pages. + */ +export 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; +} + +/** + * Slices a list for client-side pagination and keeps the page in range when the + * list shrinks underneath it (filter change, deletion, refresh). + */ +export function usePaginatedList(items: T[], page: number, pageSize: number, setPage: (page: number) => void) { + const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); + useEffect(() => { + if (page > totalPages) setPage(totalPages); + }, [page, totalPages, setPage]); + const safePage = Math.min(page, totalPages); + return items.slice((safePage - 1) * pageSize, safePage * pageSize); +} + +interface PaginationProps { + page: number; + pageSize: number; + total: number; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + /** Unique per page — the per-page { onPageSizeChange(Number(e.target.value)); onPageChange(1); }} + className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm" + > + {PAGE_SIZE_OPTIONS.map((size) => ( + + ))} + + + {(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total} + + +
+ + {getPageNumbers(page, totalPages).map((p, i) => + p === '...' ? ( + + ) : ( + + ) + )} + +
+ + ); +}