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/components/gallery/GallerySkeleton.tsx b/frontend/src/components/gallery/GallerySkeleton.tsx index 4f2046d4..defe4db9 100644 --- a/frontend/src/components/gallery/GallerySkeleton.tsx +++ b/frontend/src/components/gallery/GallerySkeleton.tsx @@ -1,31 +1,41 @@ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Skeleton, SkeletonGalleryGrid } from '../common'; /** * Loading placeholder shown while a gallery is resolving (slug → info → - * auto-login → photos). Used by GalleryPage during the pre-photos phases and - * by GalleryView while the photos query runs, so the visitor sees one - * continuous skeleton instead of multiple full-page interstitials (#321). + * auto-login → photos). The tile grid is delayed 300ms so fast loads + * never flash an empty grid before the real photos render (#321 follow-up). */ -export const GallerySkeleton: React.FC = () => ( -
-
-
-
-
- - -
-
- - +export const GallerySkeleton: React.FC = () => { + const [showGrid, setShowGrid] = useState(false); + + useEffect(() => { + const t = setTimeout(() => setShowGrid(true), 300); + return () => clearTimeout(t); + }, []); + + return ( +
+
+
+
+
+ + +
+
+ + +
-
-
-
- - + + {showGrid && ( +
+ + +
+ )}
-
-); + ); +}; diff --git a/frontend/src/components/gallery/PhotoLightbox.tsx b/frontend/src/components/gallery/PhotoLightbox.tsx index a72a727f..3e654fb8 100644 --- a/frontend/src/components/gallery/PhotoLightbox.tsx +++ b/frontend/src/components/gallery/PhotoLightbox.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef } from 'react'; import { useDevToolsProtection } from '../../hooks/useDevToolsProtection'; -import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react'; +import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react'; import type { Photo } from '../../types'; import { useDownloadPhoto } from '../../hooks/useGallery'; import { AuthenticatedImage } from '../common'; @@ -50,6 +50,21 @@ export const PhotoLightbox: React.FC = ({ // Ref (not state) so handleTouchEnd reads the value set by handleTouchStart // even when both fire in the same render batch. const swipeStartRef = useRef<{ x: number; y: number; t: number } | null>(null); + + // Carousel swipe state. A 3-slide track (prev/current/next) is shifted + // so the current slide is centered; the user's finger drags the track, + // and the track snaps to the neighbour or springs back when released. + // Percentage-based transforms avoid the need to measure the container + // before the first paint. + // - 'idle': showing the current slide, no transition + // - 'dragging': finger is down, track follows the finger (no transition) + // - 'committing': finger lifted past the threshold, animating to the + // neighbouring slot. On transitionend we advance currentIndex and reset. + // - 'springing': finger lifted below threshold, animating back to center. + const trackContainerRef = useRef(null); + const [dragX, setDragX] = useState(0); + const [phase, setPhase] = useState<'idle' | 'dragging' | 'committing' | 'springing'>('idle'); + const [commitDirection, setCommitDirection] = useState<-1 | 1>(1); const [showFeedback, setShowFeedback] = useState(initialShowFeedback); const [isSmallScreen, setIsSmallScreen] = useState(typeof window !== 'undefined' ? window.innerWidth < 640 : false); const [feedbackSettings, setFeedbackSettings] = useState<{ @@ -66,7 +81,6 @@ export const PhotoLightbox: React.FC = ({ const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [showIdentityModal, setShowIdentityModal] = useState(false); const [pendingAction, setPendingAction] = useState(null); - const [imageLoaded, setImageLoaded] = useState(false); const guestIdentity = useGuestIdentityOptional(); const isGuestMode = guestIdentity?.identityMode === 'guest'; @@ -76,11 +90,7 @@ export const PhotoLightbox: React.FC = ({ return () => window.removeEventListener('resize', onResize); }, []); - // Reset image loaded state when changing photos - useEffect(() => { - setImageLoaded(false); - }, [currentIndex]); - + const downloadPhotoMutation = useDownloadPhoto(); const currentPhoto = photos[currentIndex]; @@ -365,8 +375,9 @@ export const PhotoLightbox: React.FC = ({ } }; - // Touch event handlers: pinch-to-zoom (2 fingers) + single-finger swipe nav. - // Swipe is suppressed while zoomed in so the user can pan instead. + // Touch event handlers: pinch-to-zoom (2 fingers) + single-finger + // carousel-style swipe nav. Swipe is suppressed while zoomed in so the + // user can pan instead. The carousel is also disabled mid-animation. const handleTouchStart = (e: React.TouchEvent) => { if (e.touches.length === 2) { const touch1 = e.touches[0]; @@ -377,9 +388,21 @@ export const PhotoLightbox: React.FC = ({ ); setTouchDistance(distance); swipeStartRef.current = null; - } else if (e.touches.length === 1 && zoom <= 1) { + // Cancel any in-progress carousel motion when a pinch starts — + // spring the track back so the image doesn't jerk under the user. + if (phase === 'dragging') { + if (dragX === 0) { + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); + } + } + } else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) { const t = e.touches[0]; swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() }; + setPhase('dragging'); + setDragX(0); } }; @@ -396,26 +419,124 @@ export const PhotoLightbox: React.FC = ({ const newZoom = Math.max(1, Math.min(3, zoom * scale)); setZoom(newZoom); setTouchDistance(newDistance); + return; + } + + if (phase === 'dragging' && e.touches.length === 1 && swipeStartRef.current) { + const t = e.touches[0]; + const dx = t.clientX - swipeStartRef.current.x; + const dy = t.clientY - swipeStartRef.current.y; + // Cancel the carousel drag if the gesture turns out to be vertical + // (e.g. an accidental scroll attempt while not zoomed). If we + // haven't moved horizontally yet, snap straight to idle — there's + // no transition to wait on — otherwise let the spring carry it back. + if (Math.abs(dy) > Math.abs(dx) && Math.abs(dy) > 24) { + swipeStartRef.current = null; + if (dragX === 0) { + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); + } + return; + } + setDragX(dx); } }; const handleTouchEnd = (e: React.TouchEvent) => { setTouchDistance(null); const start = swipeStartRef.current; - if (start && e.changedTouches.length > 0) { + if (phase === 'dragging' && start && e.changedTouches.length > 0) { const t = e.changedTouches[0]; const dx = t.clientX - start.x; const dy = t.clientY - start.y; - const dt = Date.now() - start.t; - // Horizontal swipe: > 50px and dominant over vertical, completed in < 600ms. - if (Math.abs(dx) > 50 && Math.abs(dx) > Math.abs(dy) * 1.2 && dt < 600) { - if (dx > 0) goToPrevious(); - else goToNext(); + const dt = Math.max(1, Date.now() - start.t); + const velocity = Math.abs(dx) / dt; // px / ms + const containerWidth = trackContainerRef.current?.offsetWidth ?? 0; + const threshold = Math.max(60, containerWidth * 0.2); + const isHorizontal = Math.abs(dx) > Math.abs(dy) * 1.2; + const shouldCommit = isHorizontal && (Math.abs(dx) > threshold || (velocity > 0.5 && Math.abs(dx) > 40)); + + if (shouldCommit) { + setCommitDirection(dx < 0 ? 1 : -1); + setDragX(dx); + setPhase('committing'); + } else if (dragX === 0) { + // Tap with no movement — no transition would fire, so skip the + // springing phase to avoid getting stuck waiting for transitionend. + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); } + } else if (phase === 'dragging') { + // Touch ended without changedTouches data (rare) — reset cleanly. + setPhase('idle'); + setDragX(0); } swipeStartRef.current = null; }; + const handleTouchCancel = () => { + // System took over the gesture (incoming call, edge swipe, etc.). + // Spring back if the carousel was being dragged. + if (phase === 'dragging') { + if (dragX === 0) { + setPhase('idle'); + } else { + setPhase('springing'); + setDragX(0); + } + } + swipeStartRef.current = null; + setTouchDistance(null); + }; + + // Track transform. Percentages on translateX are self-referential (a + // 300%-wide track translated -33.333% moves left by exactly one container + // width), so we never need to know the container width to position the + // slides. The drag delta is added in pixels. + // - idle / springing target: -33.333% (current centered) + // - dragging: -33.333% + dragX px (finger follows) + // - committing next: -66.666% (next centered) + // - committing prev: 0% (previous centered) + const trackTransform = (() => { + if (phase === 'dragging') return `translate3d(calc(-33.3333% + ${dragX}px), 0, 0)`; + if (phase === 'committing') { + return commitDirection === 1 + ? 'translate3d(-66.6666%, 0, 0)' + : 'translate3d(0%, 0, 0)'; + } + return 'translate3d(-33.3333%, 0, 0)'; // idle | springing + })(); + + const trackTransition = phase === 'committing' || phase === 'springing' + ? 'transform 280ms cubic-bezier(0.22, 0.61, 0.36, 1)' + : 'none'; + + const handleTrackTransitionEnd = (e: React.TransitionEvent) => { + if (e.propertyName !== 'transform') return; + if (phase === 'committing') { + if (commitDirection === 1) { + setCurrentIndex((prev) => (prev < photos.length - 1 ? prev + 1 : 0)); + } else { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : photos.length - 1)); + } + setDragX(0); + setPhase('idle'); + } else if (phase === 'springing') { + setPhase('idle'); + } + }; + + const prevPhoto = photos.length > 1 + ? photos[(currentIndex - 1 + photos.length) % photos.length] + : null; + const nextPhoto = photos.length > 1 + ? photos[(currentIndex + 1) % photos.length] + : null; + // Apply protection class to the lightbox container const lightboxClass = useEnhancedProtection ? `fixed inset-0 bg-black z-50 flex items-center justify-center protected-image protection-${protectionLevel}` : @@ -566,93 +687,158 @@ export const PhotoLightbox: React.FC = ({ - {/* Image/Video container */} -
1 ? (isDragging ? 'grabbing' : 'grab') : 'default'), - right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0, - }} - > - {/* Loading spinner */} - {!imageLoaded && currentPhoto.media_type !== 'video' && ( -
- -
- )} + {/* Image/Video container. + For photos this hosts a 3-slide carousel (prev/current/next) so + swipe gestures animate the track and the neighbour images preload + while the user views the current one. Videos still render as a + single player — sliding video elements during a drag is awkward + and the carousel adds nothing for that case. */} + {(() => { + const isVideoCurrent = currentPhoto.media_type === 'video'; - {currentPhoto.media_type === 'video' ? ( - - ) : ( - { + // Reserve the slot even when there's no neighbour (single-photo + // gallery) so the flex layout keeps slides aligned. + if (!photo) { + return + ); + })()} {/* Feedback Panel */} {showFeedback && ( 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})
- + + {/* Pagination — only when the current filter has more than one page */} + {totalPages > 1 && ( +
+
+ {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 && ( + ({t('events.filtered', 'filtered')}) + )} +
+
+ + + {t('events.pageOf', { + page, + totalPages, + defaultValue: 'Page {{page}} of {{totalPages}}', + })} + + +
+
+ )} + {/* Bulk Archive Modal */} 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} />
diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index 1339c4a9..15de44b7 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -10,6 +10,7 @@ export interface DashboardStats { viewsTrend: number; downloadsTrend: number; archivedEvents: number; + totalEvents: number; } export interface SystemHealth { diff --git a/frontend/src/services/events.service.ts b/frontend/src/services/events.service.ts index 786cb3b5..b57c9fa6 100644 --- a/frontend/src/services/events.service.ts +++ b/frontend/src/services/events.service.ts @@ -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 { 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(`/admin/events?${params}`); const data: any = response.data;