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:
Paul Nothaft
2026-05-01 20:55:13 +02:00
parent 92847bc06b
commit a5b20ca3fe
6 changed files with 156 additions and 93 deletions
+9 -1
View File
@@ -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);
+1
View File
@@ -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}%`);
});
}
+11 -14
View File
@@ -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>
+120 -73
View File
@@ -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>
+1
View File
@@ -10,6 +10,7 @@ export interface DashboardStats {
viewsTrend: number;
downloadsTrend: number;
archivedEvents: number;
totalEvents: number;
}
export interface SystemHealth {
+14 -5
View File
@@ -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;