38 Commits
Author SHA1 Message Date
Michilis 183a2c99ed Merge pull request 'Restrict whole-event door takings to admin and organizer.' (#35) from dev into main
Reviewed-on: #35
2026-08-23 06:38:57 +00:00
MichilisandClaude Opus 5 745af4184f Restrict whole-event door takings to admin and organizer.
The door session sheet showed every staff member what the event had
taken overall, by tender and against pre-sale. That is management
information, not door information, and it matches the convention
already applied to the other revenue aggregates (admin/analytics,
admin/export/financial are both admin-only).

Door staff keep their own shift cash-up: the "This session" totals are
computed on the device from its own action log, so nothing they need to
reconcile at the end of the night is lost.

The gate is on GET /api/events/:eventId/door-summary, not only on the
section that renders it -- hiding the panel while the endpoint still
returned the figures would leave them one network response away. The
client skips the request entirely for staff rather than provoking a 403.

The test auth mock previously waved every role through, so it could not
have caught a wrong gate; it now honours the role list, which also puts
several already-written assertions onto real code paths.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:35:57 +00:00
Michilis 279cd19308 Merge pull request 'Fix the backend build against hono's narrowed route param type.' (#34) from dev into main
Reviewed-on: #34
2026-08-23 06:16:27 +00:00
MichilisandClaude Opus 5 02a12ee9e0 Fix the backend build against hono's narrowed route param type.
npm audit fix floated hono from 4.4 to 4.11 within the ^4.4.7 range and
tsc stopped compiling. c.req.param(key) has two overloads: it returns
string only when the route path's literal type survives inference, and
string | undefined otherwise. requireAuth() returns a handler annotated
with a bare Context, which erases the path generic, so every param read
behind it is now string | undefined.

Only four sites failed. Everywhere else the value lands in
eq((table as any).id, ...), where the any swallows it. Routes that also
run zValidator kept their typing, since that middleware is generic and
restores the inference -- door.ts:251 compiles while the two plain
requireAuth routes beside it do not.

loadEvent now accepts string | undefined and returns null for a missing
id, which both door callers already handle with their 404 branch. The
payments and tickets handlers pass the id off the row they just fetched
and null-checked rather than the raw param.

This leaves the underlying erasure in place. Typing requireAuth to
preserve the path generic would restore string on every authed route,
but it touches all of them and belongs in its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 06:15:28 +00:00
Michilis de791fd17d Merge pull request 'Dev' (#33) from dev into main
Reviewed-on: #33
2026-08-23 06:04:26 +00:00
MichilisandClaude Opus 5 ed1d3a8c12 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>
2026-08-23 06:01:27 +00:00
Michilis d06f80e572 Merge pull request 'Add mobile save sheet with same-origin photo downloads.' (#32) from dev into main
Reviewed-on: #32
2026-08-06 20:20:15 +00:00
Michilis e0f0b2f1e9 Merge pull request 'Dev' (#31) from dev into main
Reviewed-on: #31
2026-08-05 23:18:50 +00:00
Michilis 82857b5aa8 Merge pull request 'Fix post-login redirect when the session cookie is off-origin' (#30) from dev into main
Reviewed-on: #30
2026-07-29 20:46:11 +00:00
Michilis 5b478c327d Merge pull request 'Dev' (#29) from dev into main
Reviewed-on: #29
2026-07-29 19:08:28 +00:00
Michilis 6ec8b8400f Merge pull request 'dev' (#28) from dev into main
Reviewed-on: #28
2026-07-26 05:38:23 +00:00
Michilis 19461098a0 Merge pull request 'Stop auto-failing manual payments (TPago/bank/cash) after the pending TTL.' (#27) from tpago-fix into main
Reviewed-on: #27
2026-07-20 03:55:01 +00:00
Michilis 74dcc8e5ac Merge pull request 'Normalize RUC formatting to base-checkdigit across booking and admin views.' (#26) from RUC-fix into main
Reviewed-on: #26
2026-07-13 03:48:13 +00:00
Michilis bd1043a3f4 Merge pull request 'speed' (#25) from speed into main
Reviewed-on: #25
2026-07-13 01:18:23 +00:00
Michilis 107cd9753e Merge pull request 'Redesign user dashboard with overview tab and i18n payment copy.' (#24) from dashboard-update into main
Reviewed-on: #24
2026-06-29 17:00:15 +00:00
Michilis 194604004e Merge pull request 'security-update' (#23) from security-update into main
Reviewed-on: #23
2026-06-29 06:47:19 +00:00
Michilis bb920ce6f0 Merge pull request 'Add per-quantity TPago payment links for multi-ticket checkout.' (#22) from tpago-update into main
Reviewed-on: #22
2026-06-19 00:24:00 +00:00
Michilis c0315a705d Merge pull request 'Add human-readable event URL slugs with legacy redirect support.' (#21) from new-slugs into main
Reviewed-on: #21
2026-06-05 04:16:13 +00:00
Michilis fbc437a670 Merge pull request 'Fix booking flow scroll position on mobile step changes.' (#20) from dev into main
Reviewed-on: #20
2026-06-05 04:06:39 +00:00
Michilis e0f0700398 Merge pull request 'dev' (#19) from dev into main
Reviewed-on: #19
2026-06-04 23:37:15 +00:00
Michilis defd9685e0 Merge pull request 'dev' (#18) from dev into main
Reviewed-on: #18
2026-04-27 20:42:36 +00:00
Michilis 1ed62b0d3f Merge pull request 'Fix db:export ENOBUFS by streaming pg_dump output to file' (#17) from dev into main
Reviewed-on: #17
2026-03-12 19:18:57 +00:00
Michilis 91de6df04d Merge pull request 'feat(emails): add re-send for all emails, failed tab, and resend indicators' (#16) from dev into main
Reviewed-on: #16
2026-03-12 19:14:36 +00:00
Michilis a5d97d65e1 Merge pull request 'Admin: stats privacy toggle, clickable event rows, fix payment method display' (#15) from dev into main
Reviewed-on: #15
2026-03-10 01:14:36 +00:00
Michilis f0128f66b0 Merge pull request 'Bug fixes and improvements' (#14) from dev into main
Reviewed-on: #14
2026-03-07 22:53:13 +00:00
Michilis b33c68feb0 Merge pull request 'dev' (#13) from dev into main
Reviewed-on: #13
2026-02-19 02:23:19 +00:00
Michilis 15655e3987 Merge pull request 'dev' (#12) from dev into main
Reviewed-on: #12
2026-02-16 23:11:52 +00:00
Michilis d8b3864411 Merge pull request 'Fix stale featured event on homepage: revalidate cache when featured event changes' (#11) from dev into main
Reviewed-on: #11
2026-02-16 22:44:19 +00:00
Michilis 194cbd6ca8 Merge pull request 'Scanner: close button on valid ticket, camera lifecycle fix' (#10) from dev into main
Reviewed-on: #10
2026-02-14 19:04:42 +00:00
Michilis d5445c2282 Merge pull request 'Admin event page: redesign UI, export endpoints, mobile fixes' (#9) from dev into main
Reviewed-on: #9
2026-02-14 18:38:57 +00:00
Michilis dcfefc8371 Merge pull request 'feat(admin): add event attendees export (CSV) with status filters' (#8) from dev into main
Reviewed-on: #8
2026-02-14 05:28:24 +00:00
Michilis b5f14335c4 Merge pull request 'Mobile scanner redesign + backend live search' (#7) from dev into main
Reviewed-on: #7
2026-02-14 04:28:44 +00:00
Michilis d44ac949b5 Merge pull request 'Email queue + async sending; legal settings and placeholders' (#6) from dev into main
Reviewed-on: #6
2026-02-12 21:04:58 +00:00
Michilis a5e939221d Merge pull request 'dev' (#5) from dev into main
Reviewed-on: #5
2026-02-12 07:56:37 +00:00
Michilis 833e3e5a9c Merge pull request 'Fix llms.txt event times: format in America/Asuncion timezone' (#4) from dev into main
Reviewed-on: #4
2026-02-12 06:28:51 +00:00
Michilis ba1975dd6d Merge pull request 'dev' (#3) from dev into main
Reviewed-on: #3
2026-02-12 04:55:39 +00:00
Michilis 3025ef3d21 Merge pull request 'dev' (#2) from dev into main
Reviewed-on: #2
2026-02-12 03:19:06 +00:00
Michilis 8564f8af83 Merge pull request 'dev' (#1) from dev into main
Reviewed-on: #1
2026-02-12 02:18:08 +00:00
12 changed files with 319 additions and 174 deletions
+61 -6
View File
@@ -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
+10 -3
View File
@@ -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);
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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);
+28 -3
View File
@@ -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>
); );
} }
+11 -73
View File
@@ -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}&ndash;{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">&hellip;</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,6 +121,7 @@ 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">
{showEventTotals && (
<button <button
onClick={onRefresh} onClick={onRefresh}
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"
@@ -125,6 +129,7 @@ export function SessionSheet({
> >
<ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} /> <ArrowPathIcon className={clsx('w-5 h-5', summaryLoading && 'animate-spin')} />
</button> </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">
+7 -2
View File
@@ -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={() => {
+10 -73
View File
@@ -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}&ndash;{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">&hellip;</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}&ndash;{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">&hellip;</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>
);
}