Files
picpeak/frontend/src/pages/admin/EventsListPage.tsx
T

580 lines
24 KiB
TypeScript

import React, { useState, useMemo, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
Search,
Archive,
AlertTriangle,
MoreVertical,
ExternalLink,
Edit,
Download,
Trash2,
Calendar,
Image,
Activity
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
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 { isGalleryPublic } from '../../utils/accessControl';
import type { Event } from '../../types';
import { useTranslation } from 'react-i18next';
const resolveShareLink = (link: string): string => {
if (!link) return '#';
if (link.startsWith('http')) return link;
if (link.startsWith('/')) return link;
return `/gallery/${link}`;
};
export const EventsListPage: React.FC = () => {
const { t } = useTranslation();
const { format } = useLocalizedDate();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [searchTerm, setSearchTerm] = useState('');
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
const isExpiringFilter = searchParams.get('filter') === 'expiring';
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
if (activeDropdown !== null) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [activeDropdown]);
// Update dropdown position on scroll/resize
useEffect(() => {
const handleScrollOrResize = () => {
if (activeDropdown !== null) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
window.addEventListener('scroll', handleScrollOrResize, true);
window.addEventListener('resize', handleScrollOrResize);
return () => {
window.removeEventListener('scroll', handleScrollOrResize, true);
window.removeEventListener('resize', handleScrollOrResize);
};
}, [activeDropdown]);
// Fetch events
const { data, isLoading, error } = useQuery({
queryKey: ['admin-events', statusFilter],
queryFn: () => eventsService.getEvents(1, 100, (statusFilter === 'archived' || statusFilter === 'active') ? statusFilter : undefined),
});
// Archive mutation
const archiveMutation = useMutation({
mutationFn: eventsService.archiveEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('toast.eventArchived'));
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Delete mutation
const deleteMutation = useMutation({
mutationFn: eventsService.deleteEvent,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
toast.success(t('toast.deleteSuccess'));
},
onError: () => {
toast.error(t('toast.deleteError'));
},
});
// Bulk archive mutation
const bulkArchiveMutation = useMutation({
mutationFn: eventsService.bulkArchiveEvents,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['admin-events'] });
setSelectedEvents([]);
setShowBulkArchiveModal(false);
if (data.results.failed.length === 0) {
toast.success(t('events.bulkArchiveSuccess', { count: data.results.successful.length }));
} else {
toast.warning(t('events.bulkArchivePartial', { success: data.results.successful.length, failed: data.results.failed.length }));
}
},
onError: () => {
toast.error(t('toast.saveError'));
},
});
// Filter and search events
const filteredEvents = useMemo(() => {
if (!data?.events) return [];
let events = [...data.events];
// Apply status filter
if (statusFilter === 'active') {
events = events.filter(e => e.is_active && !e.is_archived);
} 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]);
const handleSelectAll = () => {
if (selectedEvents.length === filteredEvents.length) {
setSelectedEvents([]);
} else {
setSelectedEvents(filteredEvents.map(e => e.id));
}
};
const handleSelectEvent = (id: number) => {
setSelectedEvents(prev =>
prev.includes(id)
? prev.filter(i => i !== id)
: [...prev, id]
);
};
const getEventStatus = (event: Event) => {
if (event.is_archived) return { label: t('events.archived'), color: 'text-neutral-500 bg-neutral-100' };
if (!event.is_active) return { label: t('events.inactive'), color: 'text-red-600 bg-red-100' };
const days = event.expires_at ? differenceInDays(parseISO(event.expires_at), new Date()) : 0;
if (days <= 0) return { label: t('events.expired'), color: 'text-red-600 bg-red-100' };
if (days <= 7) return { label: t('events.daysLeft', { count: days }), color: 'text-orange-600 bg-orange-100' };
return { label: t('events.active'), color: 'text-green-600 bg-green-100' };
};
if (isLoading) {
return (
<div>
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p>
</div>
</div>
<SkeletonTable rows={5} />
</div>
);
}
if (error) {
return (
<div className="text-center py-12">
<p className="text-red-600">{t('events.failedToLoadEvents')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
{t('events.tryAgain')}
</Button>
</div>
);
}
return (
<ErrorBoundary>
<div>
{/* Page Header */}
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-neutral-900">{t('events.title')}</h1>
<p className="text-neutral-600 mt-1">{t('events.subtitle')}</p>
</div>
<Button
variant="primary"
leftIcon={<Plus className="w-5 h-5" />}
onClick={() => navigate('/admin/events/new')}
>
{t('events.createEvent')}
</Button>
</div>
{/* Statistics Cards */}
<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">{t('events.stats.totalEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">{data?.events.length || 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">{t('events.stats.activeEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.filter(e => e.is_active && !e.is_archived).length || 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">{t('events.stats.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 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">{t('events.stats.expiringEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
{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}
</p>
</div>
<AlertTriangle className="w-8 h-8 text-orange-600" />
</div>
</Card>
</div>
{/* Filters and Search */}
<Card padding="sm" className="mb-6">
<div className="flex flex-col lg:flex-row gap-4">
{/* Search */}
<div className="flex-1">
<Input
type="text"
placeholder={t('events.searchEventsPlaceholder')}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
{/* Filter Buttons */}
<div className="flex gap-2">
<Button
variant={!statusFilter ? 'primary' : 'outline'}
size="md"
onClick={() => {
searchParams.delete('filter');
setSearchParams(searchParams);
}}
>
{t('events.all')} ({data?.events.length || 0})
</Button>
<Button
variant={statusFilter === 'active' ? 'primary' : 'outline'}
size="md"
onClick={() => setSearchParams({ filter: 'active' })}
>
{t('events.active')}
</Button>
<Button
variant={isExpiringFilter ? 'primary' : 'outline'}
size="md"
onClick={() => setSearchParams({ filter: 'expiring' })}
leftIcon={<AlertTriangle className="w-4 h-4" />}
>
{t('events.expiring')}
</Button>
<Button
variant={statusFilter === 'archived' ? 'primary' : 'outline'}
size="md"
onClick={() => setSearchParams({ filter: 'archived' })}
leftIcon={<Archive className="w-4 h-4" />}
>
{t('events.archived')}
</Button>
</div>
</div>
{/* Bulk Actions */}
{selectedEvents.length > 0 && (
<div className="mt-4 p-3 bg-primary-50 rounded-lg flex items-center justify-between">
<span className="text-sm text-primary-900">
{t('events.eventsSelected', { count: selectedEvents.length })}
</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => setSelectedEvents([])}>
{t('events.clear')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setShowBulkArchiveModal(true)}
>
{t('events.archiveSelected')}
</Button>
</div>
</div>
)}
</Card>
{/* Events Table */}
<Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
<th className="px-6 py-3 text-left">
<input
type="checkbox"
checked={selectedEvents.length === filteredEvents.length && filteredEvents.length > 0}
onChange={handleSelectAll}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.event')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.type')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.date')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.status')}
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.expires')}
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 uppercase tracking-wider">
{t('events.actions')}
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-neutral-200">
{filteredEvents.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center text-neutral-500">
{t('events.noEventsFound')}
</td>
</tr>
) : (
filteredEvents.map((event) => {
const status = getEventStatus(event);
return (
<tr key={event.id} className="hover:bg-neutral-50">
<td className="px-6 py-4">
<input
type="checkbox"
checked={selectedEvents.includes(event.id)}
onChange={() => handleSelectEvent(event.id)}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
</td>
<td className="px-6 py-4">
<div>
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
<p className="text-xs text-neutral-500">{event.customer_email}</p>
<div className="mt-1">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
isGalleryPublic(event.require_password)
? 'bg-green-100 text-green-700'
: 'bg-neutral-100 text-neutral-700'
}`}
>
{isGalleryPublic(event.require_password) ? t('events.publicAccess', 'Public access') : t('events.passwordProtected', 'Password protected')}
</span>
</div>
</div>
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{event.event_type}
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{event.event_date ? format(parseISO(event.event_date), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${status.color}`}>
{status.label}
</span>
</td>
<td className="px-6 py-4 text-sm text-neutral-700">
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 text-right">
<div className="relative inline-block text-left dropdown-container">
<button
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setActiveDropdown(event.id);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
>
<div className="py-1">
<button
onClick={() => {
navigate(`/admin/events/${event.id}`);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<Edit className="w-4 h-4" />
{t('events.viewDetails')}
</button>
{event.share_link ? (
<a
href={resolveShareLink(event.share_link)}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
</a>
) : null}
{!event.is_archived ? (
<button
onClick={() => {
archiveMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<Archive className="w-4 h-4" />
{t('events.archiveEventAction')}
</button>
) : null}
{event.is_archived ? (
<button
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
<Download className="w-4 h-4" />
{t('events.downloadArchiveAction')}
</button>
) : null}
<button
onClick={() => {
if (confirm(t('events.deleteEventConfirm'))) {
deleteMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
{t('events.deleteEvent')}
</button>
</div>
</div>
)}
</div>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</Card>
{/* Bulk Archive Modal */}
<BulkArchiveModal
isOpen={showBulkArchiveModal}
onClose={() => setShowBulkArchiveModal(false)}
onConfirm={() => bulkArchiveMutation.mutate(selectedEvents)}
selectedEvents={filteredEvents.filter(e => selectedEvents.includes(e.id))}
isLoading={bulkArchiveMutation.isPending}
/>
</div>
</ErrorBoundary>
);
};
EventsListPage.displayName = 'EventsListPage';