diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js
index f66f4363..a5302f3e 100644
--- a/backend/src/routes/adminDashboard.js
+++ b/backend/src/routes/adminDashboard.js
@@ -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);
diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js
index fbf929fd..7ea50898 100644
--- a/backend/src/routes/adminEvents.js
+++ b/backend/src/routes/adminEvents.js
@@ -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}%`);
});
}
diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx
index ef288e38..e11a7777 100644
--- a/frontend/src/pages/admin/AdminDashboard.tsx
+++ b/frontend/src/pages/admin/AdminDashboard.tsx
@@ -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 = () => {
{t('admin.noEventsExpiring')}
) : (
- {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 = () => {
)}
- {expiringEvents.length > 5 && (
+ {expiringTotal > 5 && (
)}
diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx
index f09cf903..a1378cd2 100644
--- a/frontend/src/pages/admin/EventsListPage.tsx
+++ b/frontend/src/pages/admin/EventsListPage.tsx
@@ -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 = () => {
- {/* Statistics Cards */}
+ {/* Statistics Cards — fed from /admin/dashboard/stats so the totals
+ stay accurate regardless of the visible page (#346). */}
{t('events.stats.totalEvents')}
-
{data?.events.length || 0}
+
{dashboardStats?.totalEvents ?? 0}
-
+
{t('events.stats.activeEvents')}
- {data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
+ {dashboardStats?.activeEvents ?? 0}
-
+
{t('events.stats.totalPhotos')}
- {data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
+ {dashboardStats?.totalPhotos ?? 0}
-
+
{t('events.stats.expiringEvents')}
- {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}
@@ -351,7 +354,7 @@ export const EventsListPage: React.FC = () => {
setSearchParams(searchParams);
}}
>
- {t('events.all')} ({data?.events.length || 0})
+ {t('events.all')} ({dashboardStats?.totalEvents ?? 0})