feat: add customizable event types with admin management
Implements GitHub issue #139 - allows users to create and manage custom event types beyond the default presets (wedding, birthday, corporate, other). Backend: - Add event_types table migration with default system types - Create eventTypeService for CRUD operations with legacy fallback - Add adminEventTypes routes with full REST API - Update event validation to use dynamic event types - Update slug generation to use custom slug_prefix Frontend: - Add EventTypesPage with full CRUD admin interface - Add eventTypes.service.ts API client - Update CreateEventPage to fetch types dynamically - Add Event Types navigation in admin sidebar - Add i18n translations (EN/DE) Backward compatible: existing galleries continue to work, legacy types accepted even if database is empty via fallback mechanisms.
This commit is contained in:
@@ -24,7 +24,8 @@ import {
|
||||
SettingsPage,
|
||||
BackupManagement,
|
||||
CMSPage,
|
||||
UserManagementPage
|
||||
UserManagementPage,
|
||||
EventTypesPage
|
||||
} from './pages/admin';
|
||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
@@ -128,6 +129,7 @@ function App() {
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="event-types" element={<EventTypesPage />} />
|
||||
<Route path="backup" element={<BackupManagement />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Palette,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Users
|
||||
Users,
|
||||
Tags
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -39,6 +40,7 @@ const navigation: NavItem[] = [
|
||||
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
|
||||
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.eventTypes', href: '/admin/event-types', icon: Tags, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
|
||||
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
|
||||
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"events": "Veranstaltungen",
|
||||
"archives": "Archive",
|
||||
"settings": "Einstellungen",
|
||||
"eventTypes": "Veranstaltungsarten",
|
||||
"branding": "Branding",
|
||||
"analytics": "Analytik",
|
||||
"emailSettings": "E-Mail-Einstellungen",
|
||||
@@ -161,6 +162,54 @@
|
||||
"cmsPages": "CMS-Seiten",
|
||||
"users": "Benutzer"
|
||||
},
|
||||
"eventTypes": {
|
||||
"title": "Veranstaltungsarten",
|
||||
"subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen",
|
||||
"loading": "Lade Veranstaltungsarten...",
|
||||
"loadError": "Fehler beim Laden der Veranstaltungsarten",
|
||||
"createNew": "Neue Veranstaltungsart",
|
||||
"edit": "Veranstaltungsart bearbeiten",
|
||||
"created": "Veranstaltungsart erfolgreich erstellt",
|
||||
"updated": "Veranstaltungsart erfolgreich aktualisiert",
|
||||
"deleted": "Veranstaltungsart erfolgreich gelöscht",
|
||||
"createError": "Fehler beim Erstellen der Veranstaltungsart",
|
||||
"updateError": "Fehler beim Aktualisieren der Veranstaltungsart",
|
||||
"deleteError": "Fehler beim Löschen der Veranstaltungsart",
|
||||
"searchPlaceholder": "Veranstaltungsarten suchen...",
|
||||
"showInactive": "Inaktive anzeigen",
|
||||
"noResults": "Keine Veranstaltungsarten gefunden",
|
||||
"empty": "Noch keine Veranstaltungsarten",
|
||||
"system": "System",
|
||||
"table": {
|
||||
"type": "Typ",
|
||||
"slugPrefix": "URL-Präfix",
|
||||
"theme": "Standard-Theme",
|
||||
"status": "Status",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"form": {
|
||||
"name": "Anzeigename",
|
||||
"namePlaceholder": "z.B. Familienshooting",
|
||||
"slugPrefix": "URL-Präfix",
|
||||
"slugPrefixPlaceholder": "z.B. familie",
|
||||
"slugPreview": "Beispiel-URL:",
|
||||
"emoji": "Symbol",
|
||||
"themePreset": "Standard-Theme",
|
||||
"isActive": "Aktiv (sichtbar bei Veranstaltungserstellung)"
|
||||
},
|
||||
"validation": {
|
||||
"slugFormat": "Nur Buchstaben, Zahlen und Bindestriche erlaubt"
|
||||
},
|
||||
"slugInfo": {
|
||||
"title": "URL-Präfix Info:",
|
||||
"description": "Das URL-Präfix wird zur Generierung von Galerie-URLs verwendet. Zum Beispiel erstellt eine Veranstaltungsart mit Präfix \"familie\" URLs wie: familie-mueller-familie-2025-01-22"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Veranstaltungsart löschen",
|
||||
"message": "Sind Sie sicher, dass Sie löschen möchten",
|
||||
"warning": "Diese Aktion kann nicht rückgängig gemacht werden. Stellen Sie sicher, dass keine Veranstaltungen diesen Typ verwenden."
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"external": {
|
||||
"warning": {
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"events": "Events",
|
||||
"archives": "Archives",
|
||||
"settings": "Settings",
|
||||
"eventTypes": "Event Types",
|
||||
"branding": "Branding",
|
||||
"analytics": "Analytics",
|
||||
"emailSettings": "Email Settings",
|
||||
@@ -1445,6 +1446,54 @@
|
||||
"datenschutz": "Privacy Policy",
|
||||
"pageUpdated": "Page updated successfully"
|
||||
},
|
||||
"eventTypes": {
|
||||
"title": "Event Types",
|
||||
"subtitle": "Customize event types and their default themes",
|
||||
"loading": "Loading event types...",
|
||||
"loadError": "Failed to load event types",
|
||||
"createNew": "New Event Type",
|
||||
"edit": "Edit Event Type",
|
||||
"created": "Event type created successfully",
|
||||
"updated": "Event type updated successfully",
|
||||
"deleted": "Event type deleted successfully",
|
||||
"createError": "Failed to create event type",
|
||||
"updateError": "Failed to update event type",
|
||||
"deleteError": "Failed to delete event type",
|
||||
"searchPlaceholder": "Search event types...",
|
||||
"showInactive": "Show inactive",
|
||||
"noResults": "No event types found",
|
||||
"empty": "No event types yet",
|
||||
"system": "System",
|
||||
"table": {
|
||||
"type": "Type",
|
||||
"slugPrefix": "URL Prefix",
|
||||
"theme": "Default Theme",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"form": {
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "e.g., Family Shoot",
|
||||
"slugPrefix": "URL Prefix",
|
||||
"slugPrefixPlaceholder": "e.g., family",
|
||||
"slugPreview": "Example URL:",
|
||||
"emoji": "Icon",
|
||||
"themePreset": "Default Theme",
|
||||
"isActive": "Active (visible in event creation)"
|
||||
},
|
||||
"validation": {
|
||||
"slugFormat": "Only letters, numbers, and hyphens allowed"
|
||||
},
|
||||
"slugInfo": {
|
||||
"title": "URL Prefix Info:",
|
||||
"description": "The URL prefix is used to generate gallery URLs. For example, an event type with prefix \"family\" will create URLs like: family-smith-family-2025-01-22"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Delete Event Type",
|
||||
"message": "Are you sure you want to delete",
|
||||
"warning": "This action cannot be undone. Make sure no events are using this type."
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"external": {
|
||||
"warning": {
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { cssTemplatesService } from '../../services/cssTemplates.service';
|
||||
import { eventTypesService } from '../../services/eventTypes.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { Code } from 'lucide-react';
|
||||
@@ -57,18 +58,12 @@ interface FormData {
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_TYPE_PRESETS: Record<string, string> = {
|
||||
wedding: 'elegantWedding',
|
||||
birthday: 'birthdayFun',
|
||||
corporate: 'corporateTimeline',
|
||||
other: 'default'
|
||||
};
|
||||
|
||||
const EVENT_TYPES = [
|
||||
{ value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' },
|
||||
{ value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' },
|
||||
{ value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' },
|
||||
{ value: 'other', labelKey: 'events.types.other', emoji: '📸' },
|
||||
// Fallback event types (used when API is unavailable)
|
||||
const FALLBACK_EVENT_TYPES = [
|
||||
{ value: 'wedding', name: 'Wedding', emoji: '💒', theme_preset: 'elegantWedding' },
|
||||
{ value: 'birthday', name: 'Birthday', emoji: '🎂', theme_preset: 'birthdayFun' },
|
||||
{ value: 'corporate', name: 'Corporate', emoji: '🏢', theme_preset: 'corporateTimeline' },
|
||||
{ value: 'other', name: 'Other', emoji: '📸', theme_preset: 'default' },
|
||||
];
|
||||
|
||||
export const CreateEventPage: React.FC = () => {
|
||||
@@ -132,6 +127,22 @@ export const CreateEventPage: React.FC = () => {
|
||||
queryFn: () => cssTemplatesService.getEnabledTemplates()
|
||||
});
|
||||
|
||||
// Fetch event types
|
||||
const { data: eventTypes } = useQuery({
|
||||
queryKey: ['event-types', 'active'],
|
||||
queryFn: () => eventTypesService.getActiveEventTypes()
|
||||
});
|
||||
|
||||
// Compute event types to use (API data or fallback)
|
||||
const availableEventTypes = eventTypes?.length
|
||||
? eventTypes.map(et => ({
|
||||
value: et.slug_prefix,
|
||||
name: et.name,
|
||||
emoji: et.emoji,
|
||||
theme_preset: et.theme_preset
|
||||
}))
|
||||
: FALLBACK_EVENT_TYPES;
|
||||
|
||||
// Fetch default settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
@@ -163,7 +174,10 @@ export const CreateEventPage: React.FC = () => {
|
||||
|
||||
// Update theme when event type changes
|
||||
useEffect(() => {
|
||||
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
|
||||
// Find the selected event type's theme preset
|
||||
const selectedType = availableEventTypes.find(t => t.value === formData.event_type);
|
||||
const recommendedPreset = selectedType?.theme_preset || 'default';
|
||||
|
||||
if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -171,7 +185,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
|
||||
}));
|
||||
}
|
||||
}, [formData.event_type]);
|
||||
}, [formData.event_type, availableEventTypes]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: eventsService.createEvent,
|
||||
@@ -366,7 +380,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
{t('events.eventType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{EVENT_TYPES.map((type) => (
|
||||
{availableEventTypes.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
@@ -378,7 +392,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{type.emoji}</div>
|
||||
<div className="text-sm font-medium">{t(type.labelKey)}</div>
|
||||
<div className="text-sm font-medium">{type.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Search,
|
||||
GripVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Tags
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
|
||||
// Common emoji options for event types
|
||||
const EMOJI_OPTIONS = [
|
||||
'📷', '💒', '🎂', '🏢', '🎉', '🎊', '🎈', '👨👩👧', '💍', '🌸',
|
||||
'🎄', '🎃', '🐣', '🎓', '🏆', '🎸', '🎭', '🍽️', '🏖️', '✈️'
|
||||
];
|
||||
|
||||
export const EventTypesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingType, setEditingType] = useState<EventType | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<EventType | null>(null);
|
||||
|
||||
// Query
|
||||
const { data: eventTypes, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin-event-types', showInactive],
|
||||
queryFn: () => eventTypesService.getEventTypes(showInactive)
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: eventTypesService.createEventType,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setShowCreateModal(false);
|
||||
toast.success(t('eventTypes.created', 'Event type created successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.createError', 'Failed to create event type'));
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) =>
|
||||
eventTypesService.updateEventType(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setEditingType(null);
|
||||
toast.success(t('eventTypes.updated', 'Event type updated successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.updateError', 'Failed to update event type'));
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: eventTypesService.deleteEventType,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setDeleteConfirm(null);
|
||||
toast.success(t('eventTypes.deleted', 'Event type deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.deleteError', 'Failed to delete event type'));
|
||||
}
|
||||
});
|
||||
|
||||
// Filter event types
|
||||
const filteredTypes = useMemo(() => {
|
||||
if (!eventTypes) return [];
|
||||
if (!searchTerm) return eventTypes;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return eventTypes.filter(type =>
|
||||
type.name.toLowerCase().includes(term) ||
|
||||
type.slug_prefix.toLowerCase().includes(term)
|
||||
);
|
||||
}, [eventTypes, searchTerm]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loading size="lg" text={t('eventTypes.loading', 'Loading event types...')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">{t('eventTypes.loadError', 'Failed to load event types')}</p>
|
||||
<Button onClick={() => refetch()} className="mt-4">
|
||||
{t('common.tryAgain', 'Try Again')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 flex items-center gap-2">
|
||||
<Tags className="w-6 h-6" />
|
||||
{t('eventTypes.title', 'Event Types')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mt-1">
|
||||
{t('eventTypes.subtitle', 'Customize event types and their default themes')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
{t('eventTypes.createNew', 'New Event Type')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-4 flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={t('eventTypes.searchPlaceholder', 'Search event types...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
leftIcon={<Search className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('eventTypes.showInactive', 'Show inactive')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Event Types List */}
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase w-10">
|
||||
{/* Drag handle column */}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.type', 'Type')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.slugPrefix', 'URL Prefix')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.theme', 'Default Theme')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.status', 'Status')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.actions', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200">
|
||||
{filteredTypes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-neutral-500">
|
||||
{searchTerm
|
||||
? t('eventTypes.noResults', 'No event types found')
|
||||
: t('eventTypes.empty', 'No event types yet')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredTypes.map((type) => (
|
||||
<tr key={type.id} className={`hover:bg-neutral-50 ${!type.is_active ? 'opacity-60' : ''}`}>
|
||||
<td className="px-4 py-4">
|
||||
<GripVertical className="w-4 h-4 text-neutral-400 cursor-grab" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{type.emoji}</span>
|
||||
<div>
|
||||
<div className="font-medium text-neutral-900">{type.name}</div>
|
||||
{type.is_system && (
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t('eventTypes.system', 'System')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<code className="px-2 py-1 bg-neutral-100 rounded text-sm">
|
||||
{type.slug_prefix}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-neutral-600">
|
||||
{GALLERY_THEME_PRESETS[type.theme_preset]?.name || type.theme_preset || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{type.is_active ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">
|
||||
<Eye className="w-3 h-3" />
|
||||
{t('common.active', 'Active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-neutral-100 text-neutral-600 rounded-full text-xs">
|
||||
<EyeOff className="w-3 h-3" />
|
||||
{t('common.inactive', 'Inactive')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setEditingType(type)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg text-neutral-600 hover:text-primary-600"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
{!type.is_system && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(type)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg text-neutral-600 hover:text-red-600"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Slug Preview Info */}
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>{t('eventTypes.slugInfo.title', 'URL Prefix Info:')}</strong>{' '}
|
||||
{t('eventTypes.slugInfo.description', 'The URL prefix is used to generate gallery URLs. For example, an event type with prefix "family" will create URLs like: family-smith-family-2025-01-22')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<EventTypeModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingType && (
|
||||
<EventTypeModal
|
||||
eventType={editingType}
|
||||
onClose={() => setEditingType(null)}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editingType.id, data })}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{deleteConfirm && (
|
||||
<DeleteConfirmModal
|
||||
eventType={deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
onConfirm={() => deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Event Type Modal Component
|
||||
interface EventTypeModalProps {
|
||||
eventType?: EventType;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateEventTypeData | UpdateEventTypeData) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
eventType,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!eventType;
|
||||
|
||||
const [form, setForm] = useState<CreateEventTypeData>({
|
||||
name: eventType?.name || '',
|
||||
slug_prefix: eventType?.slug_prefix || '',
|
||||
emoji: eventType?.emoji || '📷',
|
||||
theme_preset: eventType?.theme_preset || 'default'
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateEventTypeData, string>>>({});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newErrors: typeof errors = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = t('validation.required', 'This field is required');
|
||||
}
|
||||
|
||||
if (!form.slug_prefix.trim()) {
|
||||
newErrors.slug_prefix = t('validation.required', 'This field is required');
|
||||
} else if (!/^[a-z0-9-]+$/i.test(form.slug_prefix)) {
|
||||
newErrors.slug_prefix = t('eventTypes.validation.slugFormat', 'Only letters, numbers, and hyphens allowed');
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
// For editing, only send changed fields (plus is_active if toggling)
|
||||
if (isEditing) {
|
||||
const updates: UpdateEventTypeData = {};
|
||||
if (form.name !== eventType?.name) updates.name = form.name;
|
||||
if (form.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix;
|
||||
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
|
||||
if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset;
|
||||
onSubmit(updates);
|
||||
} else {
|
||||
onSubmit(form);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-lg">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{isEditing
|
||||
? t('eventTypes.edit', 'Edit Event Type')
|
||||
: t('eventTypes.createNew', 'New Event Type')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 rounded-lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<Input
|
||||
label={t('eventTypes.form.name', 'Display Name')}
|
||||
placeholder={t('eventTypes.form.namePlaceholder', 'e.g., Family Shoot')}
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
setErrors({ ...errors, name: undefined });
|
||||
}}
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
{/* Slug Prefix */}
|
||||
<div>
|
||||
<Input
|
||||
label={t('eventTypes.form.slugPrefix', 'URL Prefix')}
|
||||
placeholder={t('eventTypes.form.slugPrefixPlaceholder', 'e.g., family')}
|
||||
value={form.slug_prefix}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
setForm({ ...form, slug_prefix: value });
|
||||
setErrors({ ...errors, slug_prefix: undefined });
|
||||
}}
|
||||
error={errors.slug_prefix}
|
||||
/>
|
||||
{form.slug_prefix && (
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{t('eventTypes.form.slugPreview', 'Example URL:')}{' '}
|
||||
<code className="bg-neutral-100 px-1 rounded">
|
||||
{form.slug_prefix}-event-name-2025-01-22
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('eventTypes.form.emoji', 'Icon')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EMOJI_OPTIONS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, emoji })}
|
||||
className={`p-2 text-xl rounded-lg border-2 transition-all ${
|
||||
form.emoji === emoji
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Preset */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('eventTypes.form.themePreset', 'Default Theme')}
|
||||
</label>
|
||||
<select
|
||||
value={form.theme_preset}
|
||||
onChange={(e) => setForm({ ...form, theme_preset: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
||||
<option key={key} value={key}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Active toggle for editing */}
|
||||
{isEditing && (
|
||||
<label className="flex items-center gap-3 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventType?.is_active}
|
||||
onChange={(e) => onSubmit({ is_active: e.target.checked })}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('eventTypes.form.isActive', 'Active (visible in event creation)')}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-neutral-200">
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" isLoading={isLoading}>
|
||||
{isEditing ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Delete Confirmation Modal
|
||||
interface DeleteConfirmModalProps {
|
||||
eventType: EventType;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
||||
eventType,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 bg-red-100 rounded-full">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{t('eventTypes.deleteConfirm.title', 'Delete Event Type')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-600 mb-4">
|
||||
{t('eventTypes.deleteConfirm.message', 'Are you sure you want to delete')} "{eventType.name}"?
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-neutral-500 bg-neutral-50 p-3 rounded-lg mb-6">
|
||||
{t('eventTypes.deleteConfirm.warning', 'This action cannot be undone. Make sure no events are using this type.')}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
isLoading={isLoading}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypesPage;
|
||||
@@ -11,4 +11,5 @@ export { SettingsPage } from './SettingsPage';
|
||||
export { CMSPage } from './CMSPage';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||
export { UserManagementPage } from './UserManagementPage';
|
||||
export { UserManagementPage } from './UserManagementPage';
|
||||
export { EventTypesPage } from './EventTypesPage';
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Event Types Service
|
||||
* API client for event type management
|
||||
*/
|
||||
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface EventType {
|
||||
id: number;
|
||||
name: string;
|
||||
slug_prefix: string;
|
||||
emoji: string;
|
||||
theme_preset: string;
|
||||
theme_config: string | null;
|
||||
display_order: number;
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateEventTypeData {
|
||||
name: string;
|
||||
slug_prefix: string;
|
||||
emoji?: string;
|
||||
theme_preset?: string;
|
||||
theme_config?: Record<string, unknown>;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateEventTypeData {
|
||||
name?: string;
|
||||
slug_prefix?: string;
|
||||
emoji?: string;
|
||||
theme_preset?: string;
|
||||
theme_config?: Record<string, unknown>;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const eventTypesService = {
|
||||
/**
|
||||
* Get all event types (for admin management)
|
||||
*/
|
||||
async getEventTypes(includeInactive = false): Promise<EventType[]> {
|
||||
const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types', {
|
||||
params: { includeInactive: includeInactive.toString() }
|
||||
});
|
||||
return response.data.eventTypes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get only active event types (for dropdowns/selection)
|
||||
*/
|
||||
async getActiveEventTypes(): Promise<EventType[]> {
|
||||
const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types/active');
|
||||
return response.data.eventTypes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single event type by ID
|
||||
*/
|
||||
async getEventType(id: number): Promise<EventType> {
|
||||
const response = await api.get<EventType>(`/admin/event-types/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new event type
|
||||
*/
|
||||
async createEventType(data: CreateEventTypeData): Promise<EventType> {
|
||||
const response = await api.post<EventType>('/admin/event-types', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an event type
|
||||
*/
|
||||
async updateEventType(id: number, data: UpdateEventTypeData): Promise<EventType> {
|
||||
const response = await api.put<EventType>(`/admin/event-types/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
*/
|
||||
async deleteEventType(id: number): Promise<void> {
|
||||
await api.delete(`/admin/event-types/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder event types
|
||||
*/
|
||||
async reorderEventTypes(orderedIds: number[]): Promise<EventType[]> {
|
||||
const response = await api.post<{ eventTypes: EventType[] }>('/admin/event-types/reorder', {
|
||||
orderedIds
|
||||
});
|
||||
return response.data.eventTypes;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user