Unify admin ticket creation into one modal with first-class payment status.
- Replace the three Attendees-tab modals (Manual Ticket / Add at Door / Invite Guest) with a single Add Ticket modal: Paid/Unpaid/Guest segmented control, shared fields, "Check in now" for all types, and a live "what happens" preview, backed by one POST /api/tickets/admin/add. - Add tickets.payment_status (paid | unpaid | comp) with a backfill migration; keep it in sync on every payment-settlement path (mark-paid, admin approval, Lightning, free bookings, hold recovery). - Show Paid/Unpaid/Comp badges in the attendee list, count only paid tickets toward revenue, let unpaid tickets be resolved via Mark Paid, and flag unpaid tickets with their balance due in the door scanner. - Replace the per-page useStatsPrivacy hook with an admin-wide PrivacyContext + SensitiveValue mask, toggled from the admin layout. - Add server-side pagination with page-size options to the users page. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e38d14970d
commit
9b2668f498
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useLanguage } from '@/context/LanguageContext';
|
||||
import { usersApi, eventsApi, User, Event } from '@/lib/api';
|
||||
import { parseDate, formatRucDisplay } from '@/lib/utils';
|
||||
@@ -9,12 +9,26 @@ 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 } from '@heroicons/react/24/outline';
|
||||
import { TrashIcon, PencilSquareIcon, FunnelIcon, XMarkIcon, MagnifyingGlassIcon, ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
import clsx from 'clsx';
|
||||
|
||||
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;
|
||||
@@ -35,6 +49,8 @@ export default function AdminUsersPage() {
|
||||
const [eventFilter, setEventFilter] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [editForm, setEditForm] = useState({
|
||||
name: '',
|
||||
@@ -57,9 +73,20 @@ export default function AdminUsersPage() {
|
||||
eventsApi.getAll().then((res) => setEvents(res.events)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
// When a filter changes, jump back to page 1 before fetching (skipping the
|
||||
// fetch for the stale page); otherwise fetch for the current page/pageSize.
|
||||
const filterKey = JSON.stringify([roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
|
||||
const prevFilterKey = useRef(filterKey);
|
||||
useEffect(() => {
|
||||
if (prevFilterKey.current !== filterKey) {
|
||||
prevFilterKey.current = filterKey;
|
||||
if (page !== 1) {
|
||||
setPage(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
loadUsers();
|
||||
}, [roleFilter, statusFilter, hasBookingsFilter, registeredRange, eventFilter, debouncedSearch]);
|
||||
}, [filterKey, page, pageSize]);
|
||||
|
||||
const hasActiveFilters =
|
||||
roleFilter || statusFilter || hasBookingsFilter || registeredRange || eventFilter || searchQuery;
|
||||
@@ -82,10 +109,16 @@ export default function AdminUsersPage() {
|
||||
registeredAfter: registeredAfterFromRange(registeredRange),
|
||||
eventId: eventFilter || undefined,
|
||||
search: debouncedSearch.trim() || undefined,
|
||||
pageSize: 200,
|
||||
page,
|
||||
pageSize,
|
||||
});
|
||||
setUsers(users);
|
||||
setTotal(total);
|
||||
// If the current page emptied out (e.g. after deleting the last user on
|
||||
// it), fall back to the new last page.
|
||||
if (users.length === 0 && total > 0 && page > 1) {
|
||||
setPage(Math.max(1, Math.ceil(total / pageSize)));
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Failed to load users');
|
||||
} finally {
|
||||
@@ -385,6 +418,65 @@ export default function AdminUsersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{total > 0 && (
|
||||
<div className="mt-4 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-600">
|
||||
<label htmlFor="users-page-size" className="whitespace-nowrap">Per page</label>
|
||||
<select
|
||||
id="users-page-size"
|
||||
value={pageSize}
|
||||
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||
className="px-2 py-1.5 rounded-btn border border-secondary-light-gray text-sm"
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||
<option key={size} value={size}>{size}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap">
|
||||
{(page - 1) * pageSize + 1}–{Math.min(page * pageSize, total)} of {total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page <= 1}
|
||||
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<ChevronLeftIcon className="w-4 h-4" />
|
||||
</button>
|
||||
{getPageNumbers(page, Math.max(1, Math.ceil(total / pageSize))).map((p, i) =>
|
||||
p === '...' ? (
|
||||
<span key={`ellipsis-${i}`} className="px-1.5 text-sm text-gray-400">…</span>
|
||||
) : (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPage(p)}
|
||||
className={clsx(
|
||||
'min-h-[36px] min-w-[36px] px-2 rounded-btn text-sm',
|
||||
p === page
|
||||
? 'bg-primary-yellow text-primary-dark font-semibold'
|
||||
: 'border border-secondary-light-gray text-gray-600 hover:bg-gray-50'
|
||||
)}
|
||||
aria-current={p === page ? 'page' : undefined}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
<button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page >= Math.ceil(total / pageSize)}
|
||||
className="p-2 rounded-btn border border-secondary-light-gray text-gray-600 hover:bg-gray-50 disabled:opacity-40 disabled:pointer-events-none min-h-[36px] min-w-[36px] flex items-center justify-center"
|
||||
aria-label="Next page"
|
||||
>
|
||||
<ChevronRightIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile Filter BottomSheet */}
|
||||
<BottomSheet open={mobileFilterOpen} onClose={() => setMobileFilterOpen(false)} title="Filters">
|
||||
<div className="space-y-4">
|
||||
|
||||
Reference in New Issue
Block a user