fix(events): server-side search/pagination to remove first-100 cap (#346)
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.
Backend
- adminEvents.js: extend search to include customer_email so the column
shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
page can render an accurate "All (N)" / Total Events counter without
walking the full table on the client.
Frontend
- events.service.ts: getEvents() now accepts search + the full status
enum (active|inactive|archived|draft|expiring); response type matches
the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
filter, and 300ms-debounced search; Prev/Next + range/page indicator
below the table; placeholderData keeps the previous page visible
during fetches; stat cards and "All (N)" pull from /dashboard/stats so
totals stay accurate regardless of the visible page; archive/delete
invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
'expiring') directly instead of slicing the first 100 client-side. As
a side effect the dashboard's "expiring" definition now matches the
backend (was excluding events expiring within the next 24h).
This commit is contained in:
@@ -62,6 +62,13 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Get total events count (all events regardless of status) — used by the
|
||||
// events list page to render accurate "All (N)" / Total Events counters
|
||||
// when the table is server-paginated (#346).
|
||||
const totalEvents = await db('events')
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
// Calculate trends (compare with previous 30 days)
|
||||
const sixtyDaysAgo = new Date();
|
||||
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
|
||||
@@ -98,7 +105,8 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
|
||||
totalDownloads: totalDownloads.count || 0,
|
||||
viewsTrend: Math.round(viewsTrend * 10) / 10,
|
||||
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
|
||||
archivedEvents: archivedEvents.count || 0
|
||||
archivedEvents: archivedEvents.count || 0,
|
||||
totalEvents: totalEvents.count || 0
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Dashboard stats error:', error);
|
||||
|
||||
@@ -739,6 +739,7 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
|
||||
query = query.where((builder) => {
|
||||
builder.where('event_name', 'like', `%${escapedSearch}%`)
|
||||
.orWhere('admin_email', 'like', `%${escapedSearch}%`)
|
||||
.orWhere('customer_email', 'like', `%${escapedSearch}%`)
|
||||
.orWhere('slug', 'like', `%${escapedSearch}%`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,10 +54,12 @@ export const AdminDashboard: React.FC = () => {
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Fetch events data for expiring events
|
||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['admin-events-summary'],
|
||||
queryFn: () => eventsService.getEvents(1, 100),
|
||||
// Fetch the next 5 expiring events directly from the server. Previously
|
||||
// this fetched the first 100 events and filtered client-side (#346 follow-up),
|
||||
// which silently missed any expiring event outside the first 100 rows.
|
||||
const { data: expiringEventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['admin-events-summary', 'expiring'],
|
||||
queryFn: () => eventsService.getEvents(1, 5, 'expiring'),
|
||||
});
|
||||
|
||||
const isLoading = statsLoading || eventsLoading;
|
||||
@@ -70,13 +72,8 @@ export const AdminDashboard: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate expiring events
|
||||
const activeEvents = eventsData?.events.filter(e => e.is_active && !e.is_archived) || [];
|
||||
const expiringEvents = activeEvents.filter(e => {
|
||||
if (!e.expires_at) return false;
|
||||
const days = differenceInDays(parseISO(e.expires_at), new Date());
|
||||
return days <= 7 && days > 0;
|
||||
});
|
||||
const expiringEvents = expiringEventsData?.events ?? [];
|
||||
const expiringTotal = expiringEventsData?.pagination?.total ?? expiringEvents.length;
|
||||
|
||||
// Format numbers for display
|
||||
const formatNumber = (num: number): string => {
|
||||
@@ -194,7 +191,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
<p className="text-neutral-600 dark:text-neutral-400 py-8 text-center">{t('admin.noEventsExpiring')}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{expiringEvents.slice(0, 5).map((event) => {
|
||||
{expiringEvents.map((event) => {
|
||||
const daysLeft = differenceInDays(parseISO(event.expires_at!), new Date());
|
||||
|
||||
return (
|
||||
@@ -225,12 +222,12 @@ export const AdminDashboard: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expiringEvents.length > 5 && (
|
||||
{expiringTotal > 5 && (
|
||||
<button
|
||||
onClick={() => navigate('/admin/events?filter=expiring')}
|
||||
className="w-full mt-4 text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-medium"
|
||||
>
|
||||
{t('admin.viewAllExpiringEvents', { count: expiringEvents.length })} →
|
||||
{t('admin.viewAllExpiringEvents', { count: expiringTotal })} →
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Archive,
|
||||
AlertTriangle,
|
||||
MoreVertical,
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
Image,
|
||||
Activity,
|
||||
Copy,
|
||||
CheckCircle
|
||||
CheckCircle,
|
||||
ChevronLeft,
|
||||
ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -23,12 +25,15 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { Button, Input, Card, SkeletonTable, ErrorBoundary } from '../../components/common';
|
||||
import { BulkArchiveModal } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { eventsService, type EventStatusFilter } from '../../services/events.service';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
import { isGalleryPublic } from '../../utils/accessControl';
|
||||
import { buildShareLinkUrl } from '../../utils/url';
|
||||
import type { Event } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export const EventsListPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -72,10 +77,31 @@ export const EventsListPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
// Get filter from URL
|
||||
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null;
|
||||
const isExpiringFilter = searchParams.get('filter') === 'expiring';
|
||||
const isDraftFilter = searchParams.get('filter') === 'draft';
|
||||
// Get filter from URL — backend supports all of these as `status` values
|
||||
const filterParam = searchParams.get('filter');
|
||||
const statusFilter: EventStatusFilter | undefined =
|
||||
filterParam === 'active' || filterParam === 'archived' ||
|
||||
filterParam === 'draft' || filterParam === 'expiring' ||
|
||||
filterParam === 'inactive'
|
||||
? filterParam
|
||||
: undefined;
|
||||
const isExpiringFilter = filterParam === 'expiring';
|
||||
const isDraftFilter = filterParam === 'draft';
|
||||
|
||||
// Server-side pagination + debounced search
|
||||
const [page, setPage] = useState(1);
|
||||
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedSearchTerm(searchTerm.trim()), 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Reset to page 1 whenever the filter or search changes so users don't
|
||||
// get stuck on a page index that no longer exists in the new result set.
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [statusFilter, debouncedSearchTerm]);
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
@@ -111,10 +137,20 @@ export const EventsListPage: React.FC = () => {
|
||||
};
|
||||
}, [activeDropdown]);
|
||||
|
||||
// Fetch events
|
||||
// Fetch events — fully server-side: pagination, status filter, and search
|
||||
// (#346 — counters and search were previously bounded to the first 100 rows).
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['admin-events', statusFilter],
|
||||
queryFn: () => eventsService.getEvents(1, 100, (statusFilter === 'archived' || statusFilter === 'active') ? statusFilter : undefined),
|
||||
queryKey: ['admin-events', statusFilter ?? 'all', debouncedSearchTerm, page],
|
||||
queryFn: () => eventsService.getEvents(page, PAGE_SIZE, statusFilter, debouncedSearchTerm || undefined),
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
// Aggregate counters come from the dashboard stats endpoint so the cards
|
||||
// and the "All (N)" filter button always reflect global totals, not the
|
||||
// currently visible page.
|
||||
const { data: dashboardStats } = useQuery({
|
||||
queryKey: ['admin-dashboard-stats'],
|
||||
queryFn: () => adminService.getDashboardStats(),
|
||||
});
|
||||
|
||||
// Archive mutation
|
||||
@@ -122,6 +158,7 @@ export const EventsListPage: React.FC = () => {
|
||||
mutationFn: eventsService.archiveEvent,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||
toast.success(t('toast.eventArchived'));
|
||||
},
|
||||
onError: () => {
|
||||
@@ -134,6 +171,7 @@ export const EventsListPage: React.FC = () => {
|
||||
mutationFn: eventsService.deleteEvent,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||
toast.success(t('toast.deleteSuccess'));
|
||||
},
|
||||
onError: () => {
|
||||
@@ -146,6 +184,7 @@ export const EventsListPage: React.FC = () => {
|
||||
mutationFn: eventsService.bulkArchiveEvents,
|
||||
onSuccess: (data) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-dashboard-stats'] });
|
||||
setSelectedEvents([]);
|
||||
setShowBulkArchiveModal(false);
|
||||
|
||||
@@ -160,52 +199,19 @@ export const EventsListPage: React.FC = () => {
|
||||
},
|
||||
});
|
||||
|
||||
// Filter and search events
|
||||
const filteredEvents = useMemo(() => {
|
||||
if (!data?.events) return [];
|
||||
|
||||
let events = [...data.events];
|
||||
|
||||
// Apply status filter
|
||||
if (isDraftFilter) {
|
||||
events = events.filter(e => e.is_draft);
|
||||
} else if (statusFilter === 'active') {
|
||||
events = events.filter(e => e.is_active && !e.is_archived && !e.is_draft);
|
||||
} else if (isExpiringFilter) {
|
||||
events = events.filter(e => {
|
||||
if (!e.is_active || e.is_archived) return false;
|
||||
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
|
||||
return days <= 7 && days > 0;
|
||||
});
|
||||
} else if (statusFilter === 'archived') {
|
||||
events = events.filter(e => e.is_archived);
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (searchTerm) {
|
||||
const term = searchTerm.toLowerCase();
|
||||
events = events.filter(e =>
|
||||
e.event_name.toLowerCase().includes(term) ||
|
||||
e.event_type.toLowerCase().includes(term) ||
|
||||
(e.customer_email || '').toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by creation date (newest first)
|
||||
events.sort((a, b) => {
|
||||
const dateA = a.created_at ? new Date(a.created_at).getTime() : 0;
|
||||
const dateB = b.created_at ? new Date(b.created_at).getTime() : 0;
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return events;
|
||||
}, [data?.events, statusFilter, searchTerm]);
|
||||
// Filtering and searching now happen server-side. Use the response directly,
|
||||
// ordered as the backend returned them (created_at desc by default).
|
||||
const events: Event[] = data?.events ?? [];
|
||||
const pagination = data?.pagination;
|
||||
const totalPages = pagination?.totalPages ?? 1;
|
||||
const filteredCount = pagination?.total ?? 0;
|
||||
const isFilteringOrSearching = !!statusFilter || !!debouncedSearchTerm;
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectedEvents.length === filteredEvents.length) {
|
||||
if (selectedEvents.length === events.length) {
|
||||
setSelectedEvents([]);
|
||||
} else {
|
||||
setSelectedEvents(filteredEvents.map(e => e.id));
|
||||
setSelectedEvents(events.map(e => e.id));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -274,52 +280,49 @@ export const EventsListPage: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
{/* Statistics Cards — fed from /admin/dashboard/stats so the totals
|
||||
stay accurate regardless of the visible page (#346). */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalEvents')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{data?.events.length || 0}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">{dashboardStats?.totalEvents ?? 0}</p>
|
||||
</div>
|
||||
<Calendar className="w-8 h-8 text-primary-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.activeEvents')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
|
||||
{dashboardStats?.activeEvents ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<Activity className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.totalPhotos')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
|
||||
{dashboardStats?.totalPhotos ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<Image className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
<Card padding="sm">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('events.stats.expiringEvents')}</p>
|
||||
<p className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{data?.events.filter(e => {
|
||||
if (!e.is_active || e.is_archived) return false;
|
||||
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
|
||||
return days <= 7 && days > 0;
|
||||
}).length || 0}
|
||||
{dashboardStats?.expiringEvents ?? 0}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="w-8 h-8 text-orange-600" />
|
||||
@@ -351,7 +354,7 @@ export const EventsListPage: React.FC = () => {
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
{t('events.all')} ({data?.events.length || 0})
|
||||
{t('events.all')} ({dashboardStats?.totalEvents ?? 0})
|
||||
</Button>
|
||||
<Button
|
||||
variant={statusFilter === 'active' ? 'primary' : 'outline'}
|
||||
@@ -417,7 +420,7 @@ export const EventsListPage: React.FC = () => {
|
||||
<th className="px-6 py-3 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedEvents.length === filteredEvents.length && filteredEvents.length > 0}
|
||||
checked={selectedEvents.length === events.length && events.length > 0}
|
||||
onChange={handleSelectAll}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 dark:border-neutral-600 rounded focus:ring-primary-500 dark:bg-neutral-700"
|
||||
/>
|
||||
@@ -443,14 +446,14 @@ export const EventsListPage: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
{filteredEvents.length === 0 ? (
|
||||
{events.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center text-neutral-500 dark:text-neutral-400">
|
||||
{t('events.noEventsFound')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredEvents.map((event) => {
|
||||
events.map((event) => {
|
||||
const status = getEventStatus(event);
|
||||
|
||||
return (
|
||||
@@ -657,13 +660,57 @@ export const EventsListPage: React.FC = () => {
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
{/* Pagination — only when the current filter has more than one page */}
|
||||
{totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div>
|
||||
{t('events.paginationLabel', {
|
||||
from: events.length === 0 ? 0 : (page - 1) * PAGE_SIZE + 1,
|
||||
to: (page - 1) * PAGE_SIZE + events.length,
|
||||
total: filteredCount,
|
||||
defaultValue: '{{from}}–{{to}} of {{total}}',
|
||||
})}
|
||||
{isFilteringOrSearching && (
|
||||
<span className="ml-2 text-neutral-400">({t('events.filtered', 'filtered')})</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<ChevronLeft className="w-4 h-4" />}
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={page <= 1}
|
||||
>
|
||||
{t('common.previous', 'Previous')}
|
||||
</Button>
|
||||
<span>
|
||||
{t('events.pageOf', {
|
||||
page,
|
||||
totalPages,
|
||||
defaultValue: 'Page {{page}} of {{totalPages}}',
|
||||
})}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
rightIcon={<ChevronRight className="w-4 h-4" />}
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={page >= totalPages}
|
||||
>
|
||||
{t('common.next', 'Next')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Archive Modal */}
|
||||
<BulkArchiveModal
|
||||
isOpen={showBulkArchiveModal}
|
||||
onClose={() => setShowBulkArchiveModal(false)}
|
||||
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
|
||||
selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
|
||||
selectedEvents={events.filter(e => selectedEvents.includes(e.id))}
|
||||
isLoading={bulkArchiveMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DashboardStats {
|
||||
viewsTrend: number;
|
||||
downloadsTrend: number;
|
||||
archivedEvents: number;
|
||||
totalEvents: number;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
|
||||
@@ -64,11 +64,16 @@ interface UpdateEventData {
|
||||
default_photo_sort?: string;
|
||||
}
|
||||
|
||||
export type EventStatusFilter = 'active' | 'inactive' | 'archived' | 'draft' | 'expiring';
|
||||
|
||||
interface EventsListResponse {
|
||||
events: Event[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pagination: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const eventsService = {
|
||||
@@ -76,16 +81,20 @@ export const eventsService = {
|
||||
async getEvents(
|
||||
page: number = 1,
|
||||
limit: number = 20,
|
||||
status?: 'active' | 'inactive' | 'archived' | 'draft'
|
||||
status?: EventStatusFilter,
|
||||
search?: string
|
||||
): Promise<EventsListResponse> {
|
||||
const params = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: limit.toString(),
|
||||
});
|
||||
|
||||
|
||||
if (status) {
|
||||
params.append('status', status);
|
||||
}
|
||||
if (search) {
|
||||
params.append('search', search);
|
||||
}
|
||||
|
||||
const response = await api.get<EventsListResponse>(`/admin/events?${params}`);
|
||||
const data: any = response.data;
|
||||
|
||||
Reference in New Issue
Block a user