Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
183a2c99ed | ||
|
|
745af4184f | ||
|
|
279cd19308 | ||
|
|
02a12ee9e0 | ||
|
|
de791fd17d | ||
|
|
ed1d3a8c12 | ||
|
|
d06f80e572 | ||
|
|
e0f0b2f1e9 | ||
|
|
82857b5aa8 | ||
|
|
5b478c327d | ||
|
|
6ec8b8400f | ||
|
|
19461098a0 | ||
|
|
74dcc8e5ac | ||
|
|
bd1043a3f4 | ||
|
|
107cd9753e | ||
|
|
194604004e | ||
|
|
bb920ce6f0 | ||
|
|
c0315a705d | ||
|
|
fbc437a670 | ||
|
|
e0f0700398 | ||
|
|
defd9685e0 | ||
|
|
1ed62b0d3f | ||
|
|
91de6df04d | ||
|
|
a5d97d65e1 | ||
|
|
f0128f66b0 | ||
|
|
b33c68feb0 | ||
|
|
15655e3987 | ||
|
|
d8b3864411 | ||
|
|
194cbd6ca8 | ||
|
|
d5445c2282 | ||
|
|
dcfefc8371 | ||
|
|
b5f14335c4 | ||
|
|
d44ac949b5 | ||
|
|
a5e939221d | ||
|
|
833e3e5a9c | ||
|
|
ba1975dd6d | ||
|
|
3025ef3d21 | ||
|
|
8564f8af83 |
@@ -14,17 +14,36 @@ process.env.BETTER_AUTH_SECRET = 'door-test-secret-0123456789abcdef';
|
|||||||
delete process.env.REDIS_URL;
|
delete process.env.REDIS_URL;
|
||||||
|
|
||||||
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
const STAFF = { id: 'staff-user-id', name: 'Door Staff', role: 'staff' };
|
||||||
|
const ADMIN = { id: 'admin-user-id', name: 'The Admin', role: 'admin' };
|
||||||
|
const ORGANIZER = { id: 'organizer-user-id', name: 'The Organizer', role: 'organizer' };
|
||||||
|
|
||||||
|
// Who the next request is from. Session auth itself is Better Auth's concern and
|
||||||
|
// has its own integration suite; this mock keeps the *role* check real so the
|
||||||
|
// tests can prove which endpoints door staff may reach.
|
||||||
|
let currentUser: { id: string; name: string; role: string } = STAFF;
|
||||||
|
|
||||||
// The door endpoints are behind staff auth; the flows under test are the writes,
|
|
||||||
// not Better Auth, which has its own integration suite.
|
|
||||||
vi.mock('../lib/auth.js', () => ({
|
vi.mock('../lib/auth.js', () => ({
|
||||||
requireAuth: () => async (c: any, next: any) => {
|
requireAuth: (roles?: string[]) => async (c: any, next: any) => {
|
||||||
c.set('user', STAFF);
|
if (roles && !roles.includes(currentUser.role)) {
|
||||||
|
return c.json({ error: 'Forbidden' }, 403);
|
||||||
|
}
|
||||||
|
c.set('user', currentUser);
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
getAuthUser: async () => STAFF,
|
getAuthUser: async () => currentUser,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
/** Run one request as a given role, always restoring the default afterwards. */
|
||||||
|
async function as<T>(user: typeof STAFF, fn: () => Promise<T>): Promise<T> {
|
||||||
|
const previous = currentUser;
|
||||||
|
currentUser = user;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
currentUser = previous;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
|
// Walk-ins with an email trigger a confirmation send; keep it out of the test.
|
||||||
vi.mock('../lib/email.js', () => ({
|
vi.mock('../lib/email.js', () => ({
|
||||||
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
|
default: { sendBookingConfirmation: vi.fn(async () => ({ success: true })) },
|
||||||
@@ -362,9 +381,45 @@ describe('undo', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('door-summary access', () => {
|
||||||
|
it('is hidden from door staff — whole-event takings are not door information', async () => {
|
||||||
|
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
|
||||||
|
expect(status).toBe(403);
|
||||||
|
// The numbers must not leak in the body either: hiding the section in the UI
|
||||||
|
// alone would still expose them to anyone reading the network response.
|
||||||
|
expect(body).not.toHaveProperty('door');
|
||||||
|
expect(body).not.toHaveProperty('presale');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is available to admin and organizer', async () => {
|
||||||
|
for (const role of [ADMIN, ORGANIZER]) {
|
||||||
|
const { status } = await as(role, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||||
|
expect(status, `${role.role} should see door takings`).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still lets door staff do their job — list, check in and undo', async () => {
|
||||||
|
expect((await get(`/api/events/${EVENT_ID}/door-attendees`)).status).toBe(200);
|
||||||
|
|
||||||
|
// Comp, so this ticket stays out of the revenue totals asserted below and
|
||||||
|
// the two tests cannot drift into each other through the shared database.
|
||||||
|
seedTicket({ id: 'tkt-role', first: 'Role', last: 'Check', status: 'confirmed', paymentStatus: 'comp' });
|
||||||
|
const checkin = await post(`/api/events/${EVENT_ID}/door-checkin`, {
|
||||||
|
ticketId: 'tkt-role',
|
||||||
|
idempotencyKey: 'key-role-check',
|
||||||
|
});
|
||||||
|
expect(checkin.status).toBe(201);
|
||||||
|
|
||||||
|
const undo = await post(`/api/events/${EVENT_ID}/door-checkin/undo`, {
|
||||||
|
idempotencyKey: 'key-role-check',
|
||||||
|
});
|
||||||
|
expect(undo.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('door-summary', () => {
|
describe('door-summary', () => {
|
||||||
it('totals door takings by tender and splits them from pre-sale', async () => {
|
it('totals door takings by tender and splits them from pre-sale', async () => {
|
||||||
const { status, body } = await get(`/api/events/${EVENT_ID}/door-summary`);
|
const { status, body } = await as(ADMIN, () => get(`/api/events/${EVENT_ID}/door-summary`));
|
||||||
expect(status).toBe(200);
|
expect(status).toBe(200);
|
||||||
|
|
||||||
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
|
// Cash: tkt-unpaid + the 'Walk' and 'Overflow' walk-ins (the undone ones are
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
|
// POST /:eventId/door-checkin the single write endpoint — checks in, settles
|
||||||
// payment, or creates a walk-in, atomically
|
// payment, or creates a walk-in, atomically
|
||||||
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
|
// POST /:eventId/door-checkin/undo reverses exactly what one keyed action did
|
||||||
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue split
|
// GET /:eventId/door-summary end-of-night cash-up + pre-sale/door revenue
|
||||||
|
// split (admin/organizer only)
|
||||||
//
|
//
|
||||||
// Every write carries a client-generated idempotencyKey. The key is inserted in
|
// Every write carries a client-generated idempotencyKey. The key is inserted in
|
||||||
// the same transaction as the writes, so a double tap or a retry after a timeout
|
// the same transaction as the writes, so a double tap or a retry after a timeout
|
||||||
@@ -35,6 +36,11 @@ import emailService from '../lib/email.js';
|
|||||||
const doorRouter = new Hono();
|
const doorRouter = new Hono();
|
||||||
|
|
||||||
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
const STAFF_ROLES = ['admin', 'organizer', 'staff'] as const;
|
||||||
|
// Whole-event money is management information, not door information: door staff
|
||||||
|
// reconcile their own shift from the session feed the client keeps locally, and
|
||||||
|
// never see what the event took overall. Matches the existing convention for
|
||||||
|
// revenue aggregates (admin/export/financial, admin/analytics).
|
||||||
|
const REVENUE_ROLES = ['admin', 'organizer'] as const;
|
||||||
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
const IDEMPOTENCY_SCOPE = 'door-checkin';
|
||||||
|
|
||||||
// ==================== Shared helpers ====================
|
// ==================== Shared helpers ====================
|
||||||
@@ -84,7 +90,8 @@ function toDoorAttendee(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEvent(eventId: string) {
|
async function loadEvent(eventId: string | undefined) {
|
||||||
|
if (!eventId) return null;
|
||||||
const event = await dbGet<any>(
|
const event = await dbGet<any>(
|
||||||
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
(db as any).select().from(events).where(eq((events as any).id, eventId))
|
||||||
);
|
);
|
||||||
@@ -572,7 +579,7 @@ doorRouter.post(
|
|||||||
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
// End-of-night reconciliation: what was taken at the door, by tender, plus the
|
||||||
// pre-sale/door split the event dashboard shows.
|
// pre-sale/door split the event dashboard shows.
|
||||||
|
|
||||||
doorRouter.get('/:eventId/door-summary', requireAuth([...STAFF_ROLES]), async (c) => {
|
doorRouter.get('/:eventId/door-summary', requireAuth([...REVENUE_ROLES]), async (c) => {
|
||||||
const eventId = c.req.param('eventId');
|
const eventId = c.req.param('eventId');
|
||||||
|
|
||||||
const event = await loadEvent(eventId);
|
const event = await loadEvent(eventId);
|
||||||
|
|||||||
@@ -667,7 +667,7 @@ paymentsRouter.post('/:id/send-reminder', requireAuth(['admin', 'organizer']), a
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await emailService.sendPaymentReminder(id);
|
const result = await emailService.sendPaymentReminder(payment.id);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const now = getNow();
|
const now = getNow();
|
||||||
|
|||||||
@@ -1174,7 +1174,7 @@ ticketsRouter.post('/:id/mark-paid', requireAuth(['admin', 'organizer', 'staff']
|
|||||||
|
|
||||||
// Send confirmation emails asynchronously (don't block the response)
|
// Send confirmation emails asynchronously (don't block the response)
|
||||||
Promise.all([
|
Promise.all([
|
||||||
emailService.sendBookingConfirmation(id),
|
emailService.sendBookingConfirmation(ticket.id),
|
||||||
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
payment ? emailService.sendPaymentReceipt(payment.id) : Promise.resolve(),
|
||||||
]).catch(err => {
|
]).catch(err => {
|
||||||
console.error('[Email] Failed to send confirmation emails:', err);
|
console.error('[Email] Failed to send confirmation emails:', err);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { useLanguage } from '@/context/LanguageContext';
|
import { useLanguage } from '@/context/LanguageContext';
|
||||||
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
import { ticketsApi, eventsApi, paymentsApi, Ticket, Event } from '@/lib/api';
|
||||||
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||||
@@ -8,6 +8,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { BottomSheet, MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
|
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
|
||||||
import {
|
import {
|
||||||
TicketIcon,
|
TicketIcon,
|
||||||
CheckCircleIcon,
|
CheckCircleIcon,
|
||||||
@@ -51,6 +52,8 @@ export default function AdminBookingsPage() {
|
|||||||
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
const [selectedPaymentStatus, setSelectedPaymentStatus] = useState<string>('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadData();
|
loadData();
|
||||||
@@ -203,6 +206,19 @@ export default function AdminBookingsPage() {
|
|||||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
(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 = {
|
const stats = {
|
||||||
total: tickets.length,
|
total: tickets.length,
|
||||||
pending: tickets.filter(t => t.status === 'pending').length,
|
pending: tickets.filter(t => t.status === 'pending').length,
|
||||||
@@ -408,7 +424,7 @@ export default function AdminBookingsPage() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
sortedTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const bookingInfo = getBookingInfo(ticket);
|
const bookingInfo = getBookingInfo(ticket);
|
||||||
return (
|
return (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50">
|
<tr key={ticket.id} className="hover:bg-gray-50">
|
||||||
@@ -502,7 +518,7 @@ export default function AdminBookingsPage() {
|
|||||||
No bookings found.
|
No bookings found.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
sortedTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const bookingInfo = getBookingInfo(ticket);
|
const bookingInfo = getBookingInfo(ticket);
|
||||||
const primary = getPrimaryAction(ticket);
|
const primary = getPrimaryAction(ticket);
|
||||||
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
|
const eventTitle = ticket.event?.title || events.find(e => e.id === ticket.eventId)?.title || 'Unknown';
|
||||||
@@ -580,6 +596,15 @@ export default function AdminBookingsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
id="bookings"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={sortedTickets.length}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile Filter BottomSheet */}
|
{/* Mobile Filter BottomSheet */}
|
||||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Ticket } from '@/lib/api';
|
import { Ticket } from '@/lib/api';
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
||||||
|
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
@@ -79,6 +81,20 @@ export function AttendeesTab({
|
|||||||
handleMarkPaid,
|
handleMarkPaid,
|
||||||
handleCheckin,
|
handleCheckin,
|
||||||
}: AttendeesTabProps) {
|
}: 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 (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Desktop toolbar */}
|
{/* Desktop toolbar */}
|
||||||
@@ -237,7 +253,7 @@ export function AttendeesTab({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const primary = getPrimaryAction(ticket);
|
const primary = getPrimaryAction(ticket);
|
||||||
return (
|
return (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
||||||
@@ -323,7 +339,7 @@ export function AttendeesTab({
|
|||||||
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
{tickets.length === 0 ? 'No attendees yet' : 'No attendees match the current filters'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredTickets.map((ticket) => {
|
pagedTickets.map((ticket) => {
|
||||||
const primary = getPrimaryAction(ticket);
|
const primary = getPrimaryAction(ticket);
|
||||||
return (
|
return (
|
||||||
<Card key={ticket.id} className="p-3">
|
<Card key={ticket.id} className="p-3">
|
||||||
@@ -380,6 +396,16 @@ export function AttendeesTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
id="attendees"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={filteredTickets.length}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
className="mb-20 md:mb-0"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
{/* Mobile FAB */}
|
||||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Ticket } from '@/lib/api';
|
import { Ticket } from '@/lib/api';
|
||||||
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
import { parseDate, EVENT_TIMEZONE } from '@/lib/utils';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
import { Dropdown, DropdownItem, MoreMenu } from '@/components/admin/MobileComponents';
|
||||||
|
import Pagination, { usePaginatedList } from '@/components/admin/Pagination';
|
||||||
import {
|
import {
|
||||||
MagnifyingGlassIcon,
|
MagnifyingGlassIcon,
|
||||||
ChevronDownIcon,
|
ChevronDownIcon,
|
||||||
@@ -48,6 +50,20 @@ export function TicketsTab({
|
|||||||
handleRemoveCheckin,
|
handleRemoveCheckin,
|
||||||
setShowTicketExportSheet,
|
setShowTicketExportSheet,
|
||||||
}: TicketsTabProps) {
|
}: TicketsTabProps) {
|
||||||
|
// Paginated client-side, same as the Attendees tab: the parent keeps the full
|
||||||
|
// ticket list for the header counts and the export actions.
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
const filterKey = `${ticketSearchQuery}|${ticketStatusFilter}`;
|
||||||
|
const prevFilterKey = useRef(filterKey);
|
||||||
|
useEffect(() => {
|
||||||
|
if (prevFilterKey.current !== filterKey) {
|
||||||
|
prevFilterKey.current = filterKey;
|
||||||
|
setPage(1);
|
||||||
|
}
|
||||||
|
}, [filterKey]);
|
||||||
|
const pagedTickets = usePaginatedList(filteredConfirmedTickets, page, pageSize, setPage);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Desktop toolbar */}
|
{/* Desktop toolbar */}
|
||||||
@@ -152,7 +168,7 @@ export function TicketsTab({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredConfirmedTickets.map((ticket) => (
|
pagedTickets.map((ticket) => (
|
||||||
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
<tr key={ticket.id} className="hover:bg-gray-50/50">
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
<p className="font-medium text-sm">{ticket.attendeeFirstName} {ticket.attendeeLastName || ''}</p>
|
||||||
@@ -216,7 +232,7 @@ export function TicketsTab({
|
|||||||
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
{confirmedTickets.length === 0 ? 'No confirmed tickets yet' : 'No tickets match the current filters'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredConfirmedTickets.map((ticket) => (
|
pagedTickets.map((ticket) => (
|
||||||
<Card key={ticket.id} className="p-3">
|
<Card key={ticket.id} className="p-3">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
@@ -253,6 +269,15 @@ export function TicketsTab({
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
id="tickets"
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={filteredConfirmedTickets.length}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={setPageSize}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,26 +9,13 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
import { MoreMenu, DropdownItem, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
||||||
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, PencilIcon, TrashIcon, EyeIcon, PhotoIcon, DocumentDuplicateIcon, ArchiveBoxIcon, StarIcon, LinkIcon } from '@heroicons/react/24/outline';
|
||||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { parseDate } from '@/lib/utils';
|
import { parseDate } from '@/lib/utils';
|
||||||
import EventFormModal from './_components/EventFormModal';
|
import EventFormModal from './_components/EventFormModal';
|
||||||
|
import Pagination from '@/components/admin/Pagination';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminEventsPage() {
|
export default function AdminEventsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -438,64 +425,15 @@ export default function AdminEventsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
<Pagination
|
||||||
{total > 0 && (
|
id="events"
|
||||||
<div className="mt-4 mb-20 md:mb-0 flex flex-col sm:flex-row items-center justify-between gap-3">
|
page={page}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
pageSize={pageSize}
|
||||||
<label htmlFor="events-page-size" className="whitespace-nowrap">Per page</label>
|
total={total}
|
||||||
<select
|
onPageChange={setPage}
|
||||||
id="events-page-size"
|
onPageSizeChange={setPageSize}
|
||||||
value={pageSize}
|
className="mb-20 md:mb-0"
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile FAB */}
|
{/* Mobile FAB */}
|
||||||
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
<div className="md:hidden fixed bottom-6 right-6 z-40">
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function SessionSheet({
|
|||||||
entries,
|
entries,
|
||||||
summary,
|
summary,
|
||||||
summaryLoading,
|
summaryLoading,
|
||||||
|
showEventTotals,
|
||||||
currency,
|
currency,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -103,6 +104,8 @@ export function SessionSheet({
|
|||||||
entries: SessionEntry[];
|
entries: SessionEntry[];
|
||||||
summary: DoorSummary | null;
|
summary: DoorSummary | null;
|
||||||
summaryLoading: boolean;
|
summaryLoading: boolean;
|
||||||
|
/** Whole-event takings are admin/organizer only; door staff see their own shift. */
|
||||||
|
showEventTotals: boolean;
|
||||||
currency: string;
|
currency: string;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -118,13 +121,15 @@ export function SessionSheet({
|
|||||||
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
|
<p className="text-xs text-gray-500">{liveEntries.length} checked in from this device</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
{showEventTotals && (
|
||||||
onClick={onRefresh}
|
<button
|
||||||
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
onClick={onRefresh}
|
||||||
aria-label="Refresh totals"
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
||||||
>
|
aria-label="Refresh totals"
|
||||||
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
|
>
|
||||||
</button>
|
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
className="min-w-[48px] min-h-[48px] flex items-center justify-center rounded-full text-gray-400 active:text-white active:scale-95 transition-all"
|
||||||
@@ -145,7 +150,9 @@ export function SessionSheet({
|
|||||||
<CashUpGrid totals={totals} currency={currency} />
|
<CashUpGrid totals={totals} currency={currency} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Whole event, from the server — the number to reconcile the cash box against */}
|
{/* Whole event, from the server — the number to reconcile the cash box
|
||||||
|
against. Admin/organizer only; the API enforces the same split. */}
|
||||||
|
{showEventTotals && (
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
|
<h2 className="text-sm font-bold text-white uppercase tracking-wide">Door total, whole event</h2>
|
||||||
@@ -175,6 +182,7 @@ export function SessionSheet({
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Feed */}
|
{/* Feed */}
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export default function AdminDoorPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const backHref = user?.role === 'staff' ? '/admin/events' : '/admin';
|
const backHref = user?.role === 'staff' ? '/admin/events' : '/admin';
|
||||||
|
// Whole-event takings are management information. Door staff reconcile their
|
||||||
|
// own shift from the session feed below, which is local to this device; the
|
||||||
|
// API enforces the same split (see REVENUE_ROLES in routes/door.ts).
|
||||||
|
const canSeeEventTotals = user?.role === 'admin' || user?.role === 'organizer';
|
||||||
|
|
||||||
// ── Events ──
|
// ── Events ──
|
||||||
const [events, setEvents] = useState<Event[]>([]);
|
const [events, setEvents] = useState<Event[]>([]);
|
||||||
@@ -531,7 +535,7 @@ export default function AdminDoorPage() {
|
|||||||
// ─── Session summary ─────────────────────────────────────────
|
// ─── Session summary ─────────────────────────────────────────
|
||||||
const loadSummary = useCallback(async () => {
|
const loadSummary = useCallback(async () => {
|
||||||
const eventId = selectedEventIdRef.current;
|
const eventId = selectedEventIdRef.current;
|
||||||
if (!eventId) return;
|
if (!eventId || !canSeeEventTotals) return;
|
||||||
setSummaryLoading(true);
|
setSummaryLoading(true);
|
||||||
try {
|
try {
|
||||||
setSummary(await doorApi.summary(eventId));
|
setSummary(await doorApi.summary(eventId));
|
||||||
@@ -540,7 +544,7 @@ export default function AdminDoorPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setSummaryLoading(false);
|
setSummaryLoading(false);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [canSeeEventTotals]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionOpen) loadSummary();
|
if (sessionOpen) loadSummary();
|
||||||
@@ -733,6 +737,7 @@ export default function AdminDoorPage() {
|
|||||||
entries={sessionEntries}
|
entries={sessionEntries}
|
||||||
summary={summary}
|
summary={summary}
|
||||||
summaryLoading={summaryLoading}
|
summaryLoading={summaryLoading}
|
||||||
|
showEventTotals={canSeeEventTotals}
|
||||||
currency={currency}
|
currency={currency}
|
||||||
onRefresh={loadSummary}
|
onRefresh={loadSummary}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
|
|||||||
@@ -9,26 +9,13 @@ import Button from '@/components/ui/Button';
|
|||||||
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
import { AdminPageSkeleton } from '@/components/ui/Skeleton';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { MoreMenu, DropdownItem, BottomSheet, AdminMobileStyles } from '@/components/admin/MobileComponents';
|
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 toast from 'react-hot-toast';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
import Pagination from '@/components/admin/Pagination';
|
||||||
|
|
||||||
type RegisteredRange = '' | '7d' | '30d' | '90d';
|
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 {
|
function registeredAfterFromRange(range: RegisteredRange): string | undefined {
|
||||||
if (!range) return undefined;
|
if (!range) return undefined;
|
||||||
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
const days = range === '7d' ? 7 : range === '30d' ? 30 : 90;
|
||||||
@@ -418,64 +405,14 @@ export default function AdminUsersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Pagination */}
|
<Pagination
|
||||||
{total > 0 && (
|
id="users"
|
||||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3">
|
page={page}
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
pageSize={pageSize}
|
||||||
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label>
|
total={total}
|
||||||
<select
|
onPageChange={setPage}
|
||||||
id="users-page-size"
|
onPageSizeChange={setPageSize}
|
||||||
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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Mobile Filter BottomSheet */}
|
{/* Mobile Filter BottomSheet */}
|
||||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||||
|
|||||||
@@ -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<T>(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 <select> needs its own id for the label. */
|
||||||
|
id: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
id,
|
||||||
|
className,
|
||||||
|
}: PaginationProps) {
|
||||||
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx('mt-4 flex flex-col sm:flex-row items-center justify-between gap-3', className)}>
|
||||||
|
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||||
|
<label htmlFor={`${id}-page-size`} className="whitespace-nowrap">Per page</label>
|
||||||
|
<select
|
||||||
|
id={`${id}-page-size`}
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { 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) => (
|
||||||
|
<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={() => onPageChange(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, totalPages).map((p, i) =>
|
||||||
|
p === '...' ? (
|
||||||
|
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(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={() => onPageChange(page + 1)}
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user