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:
Paul Nothaft
2026-01-22 13:54:23 +01:00
parent 6b3ead747b
commit f8881d5bd6
16 changed files with 1502 additions and 24 deletions
@@ -0,0 +1,107 @@
/**
* Migration: Add event_types table
*
* Creates a customizable event types system to replace hardcoded event types.
* This allows users to:
* - Rename existing event types (wedding, birthday, corporate, other)
* - Create custom event types with custom slug prefixes
* - Associate default theme presets with event types
*
* Backward compatible: Existing events keep their event_type strings.
* New events can use either legacy strings or custom event type slug_prefix.
*/
const { createTableIfNotExists, insertIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Creating event_types table...');
// Create event_types table
const hasEventTypesTable = await knex.schema.hasTable('event_types');
if (!hasEventTypesTable) {
await knex.schema.createTable('event_types', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable(); // Display name: "Family Shoot"
table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family"
table.string('emoji', 10); // Icon emoji: "👨‍👩‍👧"
table.string('theme_preset', 50); // Default theme: "elegantWedding"
table.text('theme_config'); // Custom theme JSON overrides (optional)
table.integer('display_order').defaultTo(0); // Sorting in dropdowns
table.boolean('is_system').defaultTo(false); // Protect default types
table.boolean('is_active').defaultTo(true); // Allow hiding types
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Indexes for performance
table.index('slug_prefix');
table.index('display_order');
table.index('is_active');
});
console.log('event_types table created');
} else {
console.log('event_types table already exists, skipping creation');
}
// Seed default event types (matching current hardcoded values)
const existingTypes = await knex('event_types').select('slug_prefix');
const existingSlugs = existingTypes.map(t => t.slug_prefix);
const defaultTypes = [
{
name: 'Wedding',
slug_prefix: 'wedding',
emoji: '💒',
theme_preset: 'elegantWedding',
display_order: 1,
is_system: true,
is_active: true
},
{
name: 'Birthday',
slug_prefix: 'birthday',
emoji: '🎂',
theme_preset: 'birthdayFun',
display_order: 2,
is_system: true,
is_active: true
},
{
name: 'Corporate',
slug_prefix: 'corporate',
emoji: '🏢',
theme_preset: 'corporateTimeline',
display_order: 3,
is_system: true,
is_active: true
},
{
name: 'Other',
slug_prefix: 'other',
emoji: '📸',
theme_preset: 'default',
display_order: 4,
is_system: true,
is_active: true
}
];
const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix));
if (typesToInsert.length > 0) {
await knex('event_types').insert(typesToInsert);
console.log(`Inserted ${typesToInsert.length} default event types`);
} else {
console.log('Default event types already exist, skipping seed');
}
console.log('Migration 061_add_event_types_table completed successfully');
};
exports.down = async function(knex) {
console.log('Rolling back event_types table...');
// Drop the table (data will be lost)
await knex.schema.dropTableIfExists('event_types');
console.log('event_types table dropped');
};
+1
View File
@@ -437,6 +437,7 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates')); app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename')); app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers')); app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/invite', require('./src/routes/acceptInvite')); app.use('/api/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings')); app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS')); app.use('/api/public', require('./src/routes/publicCMS'));
+258
View File
@@ -0,0 +1,258 @@
/**
* Admin Event Types Routes
* CRUD operations for managing customizable event types
*
* @module routes/adminEventTypes
*/
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const { logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const eventTypeService = require('../services/eventTypeService');
const logger = require('../utils/logger');
const router = express.Router();
/**
* GET /admin/event-types
* Get all event types (for admin management)
*/
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const includeInactive = req.query.includeInactive === 'true';
const eventTypes = await eventTypeService.getAllEventTypes({
activeOnly: !includeInactive
});
res.json({ eventTypes });
} catch (error) {
logger.error('Error fetching event types:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event types' });
}
});
/**
* GET /admin/event-types/active
* Get only active event types (for dropdowns/selection)
*/
router.get('/active', adminAuth, async (req, res) => {
try {
const eventTypes = await eventTypeService.getActiveEventTypes();
res.json({ eventTypes });
} catch (error) {
logger.error('Error fetching active event types:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event types' });
}
});
/**
* GET /admin/event-types/:id
* Get a single event type by ID
*/
router.get('/:id', adminAuth, requirePermission('settings.view'), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const eventType = await eventTypeService.getEventTypeById(parseInt(id));
if (!eventType) {
return res.status(404).json({ error: 'Event type not found' });
}
res.json(eventType);
} catch (error) {
logger.error('Error fetching event type:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event type' });
}
});
/**
* POST /admin/event-types
* Create a new event type
*/
router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().trim().withMessage('Name is required'),
body('slug_prefix')
.notEmpty()
.trim()
.isLength({ min: 2, max: 50 })
.matches(/^[a-z0-9-]+$/i)
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
body('emoji').optional().trim(),
body('theme_preset').optional().trim(),
body('theme_config').optional(),
body('display_order').optional().isInt({ min: 0 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
} = req.body;
const eventType = await eventTypeService.createEventType({
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
});
// Log activity
await logActivity('event_type_created',
{ name, slug_prefix },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.status(201).json(eventType);
} catch (error) {
logger.error('Error creating event type:', { error: error.message });
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to create event type' });
}
});
/**
* PUT /admin/event-types/:id
* Update an event type
*/
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
param('id').isInt().withMessage('Invalid event type ID'),
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
body('slug_prefix')
.optional()
.trim()
.isLength({ min: 2, max: 50 })
.matches(/^[a-z0-9-]+$/i)
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
body('emoji').optional().trim(),
body('theme_preset').optional().trim(),
body('theme_config').optional(),
body('display_order').optional().isInt({ min: 0 }),
body('is_active').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = req.body;
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
// Log activity
await logActivity('event_type_updated',
{ id, changes: Object.keys(updates) },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(eventType);
} catch (error) {
logger.error('Error updating event type:', { error: error.message });
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to update event type' });
}
});
/**
* DELETE /admin/event-types/:id
* Delete an event type (only non-system types with no events)
*/
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const result = await eventTypeService.deleteEventType(parseInt(id));
// Log activity
await logActivity('event_type_deleted',
{ id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event type deleted successfully' });
} catch (error) {
logger.error('Error deleting event type:', { error: error.message });
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to delete event type' });
}
});
/**
* POST /admin/event-types/reorder
* Reorder event types by providing an array of IDs in the desired order
*/
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { orderedIds } = req.body;
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
// Log activity
await logActivity('event_types_reordered',
{ newOrder: orderedIds },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ eventTypes, message: 'Event types reordered successfully' });
} catch (error) {
logger.error('Error reordering event types:', { error: error.message });
res.status(500).json({ error: 'Failed to reorder event types' });
}
});
module.exports = router;
+3 -1
View File
@@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
const { requirePermission } = require('../middleware/permissions'); const { requirePermission } = require('../middleware/permissions');
// Enhanced event creation with password validation // Enhanced event creation with password validation
// Note: This is a partial/reference file - dynamic event type validation should be implemented
// similar to adminEvents.js using eventTypeService.isValidEventType()
router.post('/', adminAuth, requirePermission('events.create'), [ router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
body('event_name').notEmpty().trim(), body('event_name').notEmpty().trim(),
body('event_date').isDate(), body('event_date').isDate(),
body('customer_email').isEmail().normalizeEmail(), body('customer_email').isEmail().normalizeEmail(),
+12 -1
View File
@@ -17,6 +17,7 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor
const logger = require('../utils/logger'); const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService'); const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers'); const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
// Helper to get event field requirements from settings // Helper to get event field requirements from settings
const getEventFieldRequirements = async () => { const getEventFieldRequirements = async () => {
@@ -112,7 +113,13 @@ const hasCustomerContactColumns = async () => {
// Create new event // Create new event
router.post('/', adminAuth, requirePermission('events.create'), [ router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty().trim(), body('event_name').notEmpty().trim(),
body('event_date').optional().isDate(), body('event_date').optional().isDate(),
body('customer_name').optional().trim(), body('customer_name').optional().trim(),
@@ -250,6 +257,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
} }
} }
// Get event type info for slug generation
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type);
const slugPrefix = eventTypeInfo.slug_prefix || event_type;
// Generate unique slug // Generate unique slug
const processedEventName = event_name const processedEventName = event_name
.toLowerCase() .toLowerCase()
+8 -1
View File
@@ -11,6 +11,7 @@ const path = require('path');
const router = express.Router(); const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService'); const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
// Use parseStringInput from shared parsers for customer data extraction // Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name); const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
@@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => {
// Create new event // Create new event
router.post('/', adminAuth, [ router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']), body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty(), body('event_name').notEmpty(),
body('event_date').isDate(), body('event_date').isDate(),
body('customer_name').notEmpty().trim(), body('customer_name').notEmpty().trim(),
+13 -2
View File
@@ -14,6 +14,7 @@ const { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation'); const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService'); const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers'); const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('./eventTypeService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -67,13 +68,23 @@ const mapEventForApi = (event) => {
/** /**
* Generate a unique slug for an event * Generate a unique slug for an event
* @param {string} eventType * @param {string} eventType - Event type identifier (slug_prefix or legacy type)
* @param {string} eventName * @param {string} eventName
* @param {string} eventDate * @param {string} eventDate
* @returns {Promise<string>} * @returns {Promise<string>}
*/ */
const generateUniqueSlug = async (eventType, eventName, eventDate) => { const generateUniqueSlug = async (eventType, eventName, eventDate) => {
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`; // Get the slug_prefix from event type (supports both new dynamic types and legacy)
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType);
const slugPrefix = eventTypeInfo.slug_prefix || eventType;
const processedName = eventName
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`;
let slug = baseSlug; let slug = baseSlug;
let counter = 1; let counter = 1;
+314
View File
@@ -0,0 +1,314 @@
/**
* Event Type Service Layer
* Handles all event type-related business logic
*
* @module services/eventTypeService
*/
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
/**
* Get all event types
* @param {Object} options - Filter options
* @param {boolean} options.activeOnly - Only return active types
* @returns {Promise<Array>} - Array of event types
*/
const getAllEventTypes = async (options = {}) => {
const { activeOnly = false } = options;
let query = db('event_types').select('*');
if (activeOnly) {
query = query.where('is_active', formatBoolean(true));
}
const types = await query.orderBy('display_order', 'asc');
return types;
};
/**
* Get active event types for dropdown/selection
* @returns {Promise<Array>} - Array of active event types
*/
const getActiveEventTypes = async () => {
return getAllEventTypes({ activeOnly: true });
};
/**
* Get an event type by ID
* @param {number} id - Event type ID
* @returns {Promise<Object|null>}
*/
const getEventTypeById = async (id) => {
const eventType = await db('event_types').where('id', id).first();
return eventType || null;
};
/**
* Get an event type by slug prefix
* @param {string} slugPrefix - The slug prefix
* @returns {Promise<Object|null>}
*/
const getEventTypeBySlugPrefix = async (slugPrefix) => {
const eventType = await db('event_types')
.where('slug_prefix', slugPrefix.toLowerCase())
.first();
return eventType || null;
};
/**
* Check if a slug prefix is valid (exists in event_types or is a legacy type)
* @param {string} slugPrefix - The slug prefix to validate
* @returns {Promise<boolean>}
*/
const isValidEventType = async (slugPrefix) => {
const normalized = slugPrefix.toLowerCase();
// Check in database
const eventType = await getEventTypeBySlugPrefix(normalized);
if (eventType && eventType.is_active) {
return true;
}
// Legacy fallback: Accept old hardcoded values for backward compatibility
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
return legacyTypes.includes(normalized);
};
/**
* Get all valid slug prefixes (for validation)
* @returns {Promise<string[]>}
*/
const getValidSlugPrefixes = async () => {
const types = await db('event_types')
.where('is_active', formatBoolean(true))
.select('slug_prefix');
return types.map(t => t.slug_prefix);
};
/**
* Create a new event type
* @param {Object} eventTypeData - Event type data
* @returns {Promise<Object>} - Created event type
*/
const createEventType = async (eventTypeData) => {
const {
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
} = eventTypeData;
// Normalize slug_prefix
const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Check for duplicate slug_prefix
const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix);
if (existing) {
const error = new Error('An event type with this slug prefix already exists');
error.code = 'DUPLICATE_SLUG_PREFIX';
throw error;
}
// Get max display order if not provided
let finalDisplayOrder = display_order;
if (finalDisplayOrder === undefined || finalDisplayOrder === null) {
const maxOrder = await db('event_types').max('display_order as max').first();
finalDisplayOrder = (maxOrder?.max || 0) + 1;
}
const insertData = {
name,
slug_prefix: normalizedSlugPrefix,
emoji: emoji || '📷',
theme_preset: theme_preset || 'default',
theme_config: theme_config ? JSON.stringify(theme_config) : null,
display_order: finalDisplayOrder,
is_system: false,
is_active: true,
created_at: new Date(),
updated_at: new Date()
};
const insertResult = await db('event_types').insert(insertData).returning('id');
const eventTypeId = insertResult[0]?.id || insertResult[0];
return getEventTypeById(eventTypeId);
};
/**
* Update an event type
* @param {number} id - Event type ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} - Updated event type
*/
const updateEventType = async (id, updates) => {
const eventType = await getEventTypeById(id);
if (!eventType) {
const error = new Error('Event type not found');
error.code = 'NOT_FOUND';
throw error;
}
// Build update object
const updateData = {};
if (updates.name !== undefined) {
updateData.name = updates.name;
}
if (updates.slug_prefix !== undefined) {
const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Check for duplicate (excluding current)
const existing = await db('event_types')
.where('slug_prefix', normalizedSlugPrefix)
.whereNot('id', id)
.first();
if (existing) {
const error = new Error('An event type with this slug prefix already exists');
error.code = 'DUPLICATE_SLUG_PREFIX';
throw error;
}
updateData.slug_prefix = normalizedSlugPrefix;
}
if (updates.emoji !== undefined) {
updateData.emoji = updates.emoji;
}
if (updates.theme_preset !== undefined) {
updateData.theme_preset = updates.theme_preset;
}
if (updates.theme_config !== undefined) {
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
}
if (updates.display_order !== undefined) {
updateData.display_order = updates.display_order;
}
if (updates.is_active !== undefined) {
updateData.is_active = formatBoolean(updates.is_active);
}
updateData.updated_at = new Date();
await db('event_types').where('id', id).update(updateData);
return getEventTypeById(id);
};
/**
* Delete an event type
* @param {number} id - Event type ID
* @returns {Promise<Object>}
*/
const deleteEventType = async (id) => {
const eventType = await getEventTypeById(id);
if (!eventType) {
const error = new Error('Event type not found');
error.code = 'NOT_FOUND';
throw error;
}
// Prevent deletion of system types
if (eventType.is_system) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
}
// Check if any events use this type
const eventsUsingType = await db('events')
.where('event_type', eventType.slug_prefix)
.count('id as count')
.first();
if (eventsUsingType && parseInt(eventsUsingType.count) > 0) {
const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`);
error.code = 'IN_USE';
throw error;
}
await db('event_types').where('id', id).del();
return { success: true, deleted: eventType };
};
/**
* Reorder event types
* @param {Array} orderedIds - Array of IDs in new order
* @returns {Promise<Array>}
*/
const reorderEventTypes = async (orderedIds) => {
await db.transaction(async (trx) => {
for (let i = 0; i < orderedIds.length; i++) {
await trx('event_types')
.where('id', orderedIds[i])
.update({ display_order: i + 1, updated_at: new Date() });
}
});
return getAllEventTypes();
};
/**
* Get event type info for slug generation
* Returns the slug_prefix to use for a given event type identifier
* @param {string} eventTypeIdentifier - Either an ID or slug_prefix
* @returns {Promise<Object>} - Event type with slug_prefix and theme_preset
*/
const getEventTypeForSlug = async (eventTypeIdentifier) => {
// Try to find by slug_prefix first
let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier);
if (eventType) {
return eventType;
}
// Try by ID if numeric
if (!isNaN(eventTypeIdentifier)) {
eventType = await getEventTypeById(parseInt(eventTypeIdentifier));
if (eventType) {
return eventType;
}
}
// Fallback for legacy types - return a compatible object
const legacyDefaults = {
wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' },
birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' },
corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' },
other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' }
};
const normalized = eventTypeIdentifier.toLowerCase();
if (legacyDefaults[normalized]) {
return legacyDefaults[normalized];
}
// Default fallback
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
};
module.exports = {
getAllEventTypes,
getActiveEventTypes,
getEventTypeById,
getEventTypeBySlugPrefix,
isValidEventType,
getValidSlugPrefixes,
createEventType,
updateEventType,
deleteEventType,
reorderEventTypes,
getEventTypeForSlug
};
+3 -1
View File
@@ -24,7 +24,8 @@ import {
SettingsPage, SettingsPage,
BackupManagement, BackupManagement,
CMSPage, CMSPage,
UserManagementPage UserManagementPage,
EventTypesPage
} from './pages/admin'; } from './pages/admin';
import { AcceptInvitePage } from './pages/public/AcceptInvitePage'; import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
import { AdminLayout, AdminAuthWrapper } from './components/admin'; import { AdminLayout, AdminAuthWrapper } from './components/admin';
@@ -128,6 +129,7 @@ function App() {
<Route path="analytics" element={<AnalyticsPage />} /> <Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} /> <Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="event-types" element={<EventTypesPage />} />
<Route path="backup" element={<BackupManagement />} /> <Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPage />} /> <Route path="cms" element={<CMSPage />} />
<Route path="users" element={<UserManagementPage />} /> <Route path="users" element={<UserManagementPage />} />
@@ -11,7 +11,8 @@ import {
Palette, Palette,
FileText, FileText,
HardDrive, HardDrive,
Users Users,
Tags
} from 'lucide-react'; } from 'lucide-react';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; 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.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.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.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.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' }, { nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' }, { nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
+49
View File
@@ -154,6 +154,7 @@
"events": "Veranstaltungen", "events": "Veranstaltungen",
"archives": "Archive", "archives": "Archive",
"settings": "Einstellungen", "settings": "Einstellungen",
"eventTypes": "Veranstaltungsarten",
"branding": "Branding", "branding": "Branding",
"analytics": "Analytik", "analytics": "Analytik",
"emailSettings": "E-Mail-Einstellungen", "emailSettings": "E-Mail-Einstellungen",
@@ -161,6 +162,54 @@
"cmsPages": "CMS-Seiten", "cmsPages": "CMS-Seiten",
"users": "Benutzer" "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": { "backup": {
"external": { "external": {
"warning": { "warning": {
+49
View File
@@ -154,6 +154,7 @@
"events": "Events", "events": "Events",
"archives": "Archives", "archives": "Archives",
"settings": "Settings", "settings": "Settings",
"eventTypes": "Event Types",
"branding": "Branding", "branding": "Branding",
"analytics": "Analytics", "analytics": "Analytics",
"emailSettings": "Email Settings", "emailSettings": "Email Settings",
@@ -1445,6 +1446,54 @@
"datenschutz": "Privacy Policy", "datenschutz": "Privacy Policy",
"pageUpdated": "Page updated successfully" "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": { "backup": {
"external": { "external": {
"warning": { "warning": {
+30 -16
View File
@@ -21,6 +21,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { categoriesService } from '../../services/categories.service'; import { categoriesService } from '../../services/categories.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { cssTemplatesService } from '../../services/cssTemplates.service'; import { cssTemplatesService } from '../../services/cssTemplates.service';
import { eventTypesService } from '../../services/eventTypes.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { Code } from 'lucide-react'; import { Code } from 'lucide-react';
@@ -57,18 +58,12 @@ interface FormData {
}; };
} }
const EVENT_TYPE_PRESETS: Record<string, string> = { // Fallback event types (used when API is unavailable)
wedding: 'elegantWedding', const FALLBACK_EVENT_TYPES = [
birthday: 'birthdayFun', { value: 'wedding', name: 'Wedding', emoji: '💒', theme_preset: 'elegantWedding' },
corporate: 'corporateTimeline', { value: 'birthday', name: 'Birthday', emoji: '🎂', theme_preset: 'birthdayFun' },
other: 'default' { value: 'corporate', name: 'Corporate', emoji: '🏢', theme_preset: 'corporateTimeline' },
}; { value: 'other', name: 'Other', emoji: '📸', theme_preset: '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: '📸' },
]; ];
export const CreateEventPage: React.FC = () => { export const CreateEventPage: React.FC = () => {
@@ -132,6 +127,22 @@ export const CreateEventPage: React.FC = () => {
queryFn: () => cssTemplatesService.getEnabledTemplates() 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 // Fetch default settings
const { data: settings } = useQuery({ const { data: settings } = useQuery({
queryKey: ['admin-settings'], queryKey: ['admin-settings'],
@@ -163,7 +174,10 @@ export const CreateEventPage: React.FC = () => {
// Update theme when event type changes // Update theme when event type changes
useEffect(() => { 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]) { if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
@@ -171,7 +185,7 @@ export const CreateEventPage: React.FC = () => {
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
})); }));
} }
}, [formData.event_type]); }, [formData.event_type, availableEventTypes]);
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: eventsService.createEvent, mutationFn: eventsService.createEvent,
@@ -366,7 +380,7 @@ export const CreateEventPage: React.FC = () => {
{t('events.eventType')} {t('events.eventType')}
</label> </label>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{EVENT_TYPES.map((type) => ( {availableEventTypes.map((type) => (
<button <button
key={type.value} key={type.value}
type="button" type="button"
@@ -378,7 +392,7 @@ export const CreateEventPage: React.FC = () => {
}`} }`}
> >
<div className="text-2xl mb-1">{type.emoji}</div> <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> </button>
))} ))}
</div> </div>
+550
View File
@@ -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;
+1
View File
@@ -12,3 +12,4 @@ export { CMSPage } from './CMSPage';
export { BackupManagement } from './BackupManagement'; export { BackupManagement } from './BackupManagement';
export { EventFeedbackPage } from './EventFeedbackPage'; export { EventFeedbackPage } from './EventFeedbackPage';
export { UserManagementPage } from './UserManagementPage'; export { UserManagementPage } from './UserManagementPage';
export { EventTypesPage } from './EventTypesPage';
+100
View File
@@ -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;
}
};