feat(admin): Implement comprehensive admin dashboard system
Create central dashboard with statistics and quick actions Build events management with filtering and CRUD UI Implement members management with bulk actions Develop video management with accessibility indicators Add document repository with visibility controls Create content management with WYSIWYG editor Add settings interface for site configuration Additional improvements: Implement full Events Management System with CRUD Enhance frontend architecture with TypeScript Improve accessibility in navigation menu Add comprehensive test suite for Events API
This commit is contained in:
parent
f3c9d01f51
commit
8e986796e0
16 changed files with 2648 additions and 148 deletions
232
backend/__tests__/events.test.ts
Normal file
232
backend/__tests__/events.test.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import mongoose from 'mongoose';
|
||||
import request from 'supertest';
|
||||
import express from 'express';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { jest, describe, it, expect, beforeAll, afterAll, beforeEach } from '@jest/globals';
|
||||
import Event from '../src/models/Event';
|
||||
import eventRoutes from '../src/routes/events';
|
||||
|
||||
// Mock auth middleware
|
||||
jest.mock('../src/middleware/auth', () => ({
|
||||
checkAuth: (req: any, res: any, next: any) => next(),
|
||||
}));
|
||||
|
||||
describe('Events API', () => {
|
||||
let app: express.Application;
|
||||
let mongoServer: MongoMemoryServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up MongoDB Memory Server
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const uri = mongoServer.getUri();
|
||||
await mongoose.connect(uri);
|
||||
|
||||
// Set up Express app
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/events', eventRoutes);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clear the database before each test
|
||||
await Event.deleteMany({});
|
||||
});
|
||||
|
||||
// Sample event data
|
||||
const sampleEvent = {
|
||||
title: 'Test Event',
|
||||
description: 'This is a test event',
|
||||
date: new Date('2025-05-15'),
|
||||
time: '18:00 - 20:00',
|
||||
location: 'Test Location',
|
||||
category: 'social',
|
||||
status: 'published',
|
||||
registrationRequired: true,
|
||||
maxAttendees: 50,
|
||||
};
|
||||
|
||||
describe('GET /api/events', () => {
|
||||
it('should return an empty array when no events exist', async () => {
|
||||
const response = await request(app).get('/api/events');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.events).toEqual([]);
|
||||
expect(response.body.pagination.total).toBe(0);
|
||||
});
|
||||
|
||||
it('should return events when they exist', async () => {
|
||||
// Create a test event
|
||||
await Event.create(sampleEvent);
|
||||
|
||||
const response = await request(app).get('/api/events');
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.events.length).toBe(1);
|
||||
expect(response.body.events[0].title).toBe('Test Event');
|
||||
expect(response.body.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter events by status', async () => {
|
||||
// Create published event
|
||||
await Event.create(sampleEvent);
|
||||
|
||||
// Create draft event
|
||||
await Event.create({
|
||||
...sampleEvent,
|
||||
title: 'Draft Event',
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
// Test filtering by published status
|
||||
const publishedResponse = await request(app).get('/api/events?status=published');
|
||||
expect(publishedResponse.status).toBe(200);
|
||||
expect(publishedResponse.body.events.length).toBe(1);
|
||||
expect(publishedResponse.body.events[0].title).toBe('Test Event');
|
||||
|
||||
// Test filtering by draft status
|
||||
const draftResponse = await request(app).get('/api/events?status=draft');
|
||||
expect(draftResponse.status).toBe(200);
|
||||
expect(draftResponse.body.events.length).toBe(1);
|
||||
expect(draftResponse.body.events[0].title).toBe('Draft Event');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/events/:id', () => {
|
||||
it('should return 404 for non-existent event', async () => {
|
||||
const nonExistentId = new mongoose.Types.ObjectId();
|
||||
const response = await request(app).get(`/api/events/${nonExistentId}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
|
||||
it('should return the event when it exists', async () => {
|
||||
// Create a test event
|
||||
const createdEvent = await Event.create(sampleEvent);
|
||||
|
||||
const response = await request(app).get(`/api/events/${createdEvent._id}`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.event.title).toBe('Test Event');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/events', () => {
|
||||
it('should create a new event', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/events')
|
||||
.send(sampleEvent);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.event.title).toBe('Test Event');
|
||||
|
||||
// Verify event was saved to database
|
||||
const events = await Event.find();
|
||||
expect(events.length).toBe(1);
|
||||
expect(events[0].title).toBe('Test Event');
|
||||
});
|
||||
|
||||
it('should return 400 for invalid event data', async () => {
|
||||
// Missing required fields
|
||||
const invalidEvent = {
|
||||
title: 'Invalid Event',
|
||||
// Missing description, date, time, location, category
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/events')
|
||||
.send(invalidEvent);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/events/:id', () => {
|
||||
it('should update an existing event', async () => {
|
||||
// Create a test event
|
||||
const createdEvent = await Event.create(sampleEvent);
|
||||
|
||||
// Update the event
|
||||
const updatedData = {
|
||||
title: 'Updated Event Title',
|
||||
description: 'Updated description',
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${createdEvent._id}`)
|
||||
.send(updatedData);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.event.title).toBe('Updated Event Title');
|
||||
expect(response.body.event.description).toBe('Updated description');
|
||||
|
||||
// Verify event was updated in database
|
||||
const updatedEvent = await Event.findById(createdEvent._id);
|
||||
expect(updatedEvent?.title).toBe('Updated Event Title');
|
||||
expect(updatedEvent?.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent event', async () => {
|
||||
const nonExistentId = new mongoose.Types.ObjectId();
|
||||
const response = await request(app)
|
||||
.put(`/api/events/${nonExistentId}`)
|
||||
.send({ title: 'Updated Title' });
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/events/:id', () => {
|
||||
it('should delete an existing event', async () => {
|
||||
// Create a test event
|
||||
const createdEvent = await Event.create(sampleEvent);
|
||||
|
||||
const response = await request(app).delete(`/api/events/${createdEvent._id}`);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify event was deleted from database
|
||||
const events = await Event.find();
|
||||
expect(events.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should return 404 for non-existent event', async () => {
|
||||
const nonExistentId = new mongoose.Types.ObjectId();
|
||||
const response = await request(app).delete(`/api/events/${nonExistentId}`);
|
||||
expect(response.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/events/:id/register', () => {
|
||||
it('should register for an event', async () => {
|
||||
// Create a test event
|
||||
const createdEvent = await Event.create(sampleEvent);
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/api/events/${createdEvent._id}/register`)
|
||||
.send({ userId: new mongoose.Types.ObjectId() });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Verify registration count was incremented
|
||||
const updatedEvent = await Event.findById(createdEvent._id);
|
||||
expect(updatedEvent?.registeredCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should return 400 when event is at capacity', async () => {
|
||||
// Create a test event at capacity
|
||||
const eventAtCapacity = await Event.create({
|
||||
...sampleEvent,
|
||||
maxAttendees: 5,
|
||||
registeredCount: 5,
|
||||
});
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/api/events/${eventAtCapacity._id}/register`)
|
||||
.send({ userId: new mongoose.Types.ObjectId() });
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.error.message).toContain('capacity');
|
||||
});
|
||||
});
|
||||
});
|
||||
25
backend/__tests__/setup.ts
Normal file
25
backend/__tests__/setup.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Global test setup
|
||||
import mongoose from 'mongoose';
|
||||
import { jest, afterAll } from '@jest/globals';
|
||||
|
||||
// Increase timeout for tests
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Silence console logs during tests
|
||||
global.console = {
|
||||
...console,
|
||||
log: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
// Keep error and warn for debugging
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
};
|
||||
|
||||
// Clean up after all tests
|
||||
afterAll(async () => {
|
||||
// Close mongoose connection if open
|
||||
if (mongoose.connection.readyState !== 0) {
|
||||
await mongoose.disconnect();
|
||||
}
|
||||
});
|
||||
25
backend/jest.config.js
Normal file
25
backend/jest.config.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
export default {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
extensionsToTreatAsEsm: ['.ts'],
|
||||
moduleNameMapper: {
|
||||
'^(\\.{1,2}/.*)\\.js$': '$1',
|
||||
},
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
useESM: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.ts',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/index.ts',
|
||||
],
|
||||
coverageDirectory: 'coverage',
|
||||
verbose: true,
|
||||
setupFilesAfterEnv: ['./__tests__/setup.ts'],
|
||||
};
|
||||
|
|
@ -7,7 +7,8 @@
|
|||
"start": "node dist/index.js",
|
||||
"dev": "tsx --watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"lint": "eslint src"
|
||||
"lint": "eslint src",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
|
|
@ -27,13 +28,19 @@
|
|||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/node": "^20.11.14",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/supertest": "^6.0.2",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"mongodb-memory-server": "^9.1.6",
|
||||
"supertest": "^6.3.4",
|
||||
"ts-jest": "^29.1.2",
|
||||
"tsx": "^4.7.0",
|
||||
"typescript": "^5.8.0"
|
||||
}
|
||||
|
|
|
|||
410
backend/src/controllers/eventController.ts
Normal file
410
backend/src/controllers/eventController.ts
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
import { Request, Response } from 'express';
|
||||
import Event, { IEvent } from '../models/Event';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
/**
|
||||
* Get all events with optional filtering
|
||||
*/
|
||||
export const getEvents = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
status,
|
||||
category,
|
||||
search,
|
||||
startDate,
|
||||
endDate,
|
||||
page = 1,
|
||||
limit = 10
|
||||
} = req.query;
|
||||
|
||||
// Build query
|
||||
const query: any = {};
|
||||
|
||||
// Filter by status if provided
|
||||
if (status && status !== 'all') {
|
||||
query.status = status;
|
||||
}
|
||||
|
||||
// Filter by category if provided
|
||||
if (category && category !== 'all') {
|
||||
query.category = category;
|
||||
}
|
||||
|
||||
// Filter by date range if provided
|
||||
if (startDate || endDate) {
|
||||
query.date = {};
|
||||
if (startDate) {
|
||||
query.date.$gte = new Date(startDate as string);
|
||||
}
|
||||
if (endDate) {
|
||||
query.date.$lte = new Date(endDate as string);
|
||||
}
|
||||
}
|
||||
|
||||
// Text search if provided
|
||||
if (search) {
|
||||
query.$text = { $search: search as string };
|
||||
}
|
||||
|
||||
// Calculate pagination
|
||||
const skip = ((Number(page) || 1) - 1) * (Number(limit) || 10);
|
||||
|
||||
// Execute query with pagination
|
||||
const events = await Event.find(query)
|
||||
.sort({ date: 1 }) // Sort by date ascending
|
||||
.skip(skip)
|
||||
.limit(Number(limit));
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await Event.countDocuments(query);
|
||||
|
||||
return res.status(200).json({
|
||||
events,
|
||||
pagination: {
|
||||
total,
|
||||
page: Number(page),
|
||||
limit: Number(limit),
|
||||
pages: Math.ceil(total / Number(limit))
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching events:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch events'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single event by ID
|
||||
*/
|
||||
export const getEventById = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid event ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find event
|
||||
const event = await Event.findById(id);
|
||||
|
||||
// Check if event exists
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Event not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ event });
|
||||
} catch (error) {
|
||||
console.error('Error fetching event:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to fetch event'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
*/
|
||||
export const createEvent = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const {
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
time,
|
||||
location,
|
||||
category,
|
||||
status,
|
||||
registrationRequired,
|
||||
maxAttendees,
|
||||
recurring,
|
||||
recurrencePattern,
|
||||
tags
|
||||
} = req.body;
|
||||
|
||||
// Create new event
|
||||
const event = new Event({
|
||||
title,
|
||||
description,
|
||||
date,
|
||||
time,
|
||||
location,
|
||||
category,
|
||||
status: status || 'draft',
|
||||
registrationRequired: registrationRequired || false,
|
||||
registeredCount: 0,
|
||||
maxAttendees,
|
||||
recurring: recurring || false,
|
||||
recurrencePattern,
|
||||
tags
|
||||
});
|
||||
|
||||
// Save event to database
|
||||
await event.save();
|
||||
|
||||
return res.status(201).json({
|
||||
message: 'Event created successfully',
|
||||
event
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof mongoose.Error.ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation error',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to create event'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
*/
|
||||
export const updateEvent = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const updateData = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid event ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and update event
|
||||
const event = await Event.findByIdAndUpdate(
|
||||
id,
|
||||
updateData,
|
||||
{ new: true, runValidators: true }
|
||||
);
|
||||
|
||||
// Check if event exists
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Event not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Event updated successfully',
|
||||
event
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error updating event:', error);
|
||||
|
||||
// Handle validation errors
|
||||
if (error instanceof mongoose.Error.ValidationError) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation error',
|
||||
details: error.errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to update event'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
export const deleteEvent = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid event ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find and delete event
|
||||
const event = await Event.findByIdAndDelete(id);
|
||||
|
||||
// Check if event exists
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Event not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Event deleted successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error deleting event:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to delete event'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Register for an event
|
||||
*/
|
||||
export const registerForEvent = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { userId } = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid event ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find event
|
||||
const event = await Event.findById(id);
|
||||
|
||||
// Check if event exists
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Event not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if registration is required
|
||||
if (!event.registrationRequired) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Registration is not required for this event'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if event is at capacity
|
||||
if (event.maxAttendees && event.registeredCount >= event.maxAttendees) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Event is at full capacity'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Increment registered count
|
||||
event.registeredCount += 1;
|
||||
await event.save();
|
||||
|
||||
// TODO: In a real implementation, we would also save the user's registration
|
||||
// in a separate EventRegistration collection
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Successfully registered for event',
|
||||
event
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error registering for event:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to register for event'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Cancel registration for an event
|
||||
*/
|
||||
export const cancelRegistration = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { userId } = req.body;
|
||||
|
||||
// Validate ID format
|
||||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Invalid event ID format'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find event
|
||||
const event = await Event.findById(id);
|
||||
|
||||
// Check if event exists
|
||||
if (!event) {
|
||||
return res.status(404).json({
|
||||
error: {
|
||||
message: 'Event not found'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check if registration is required
|
||||
if (!event.registrationRequired) {
|
||||
return res.status(400).json({
|
||||
error: {
|
||||
message: 'Registration is not required for this event'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Decrement registered count (ensure it doesn't go below 0)
|
||||
if (event.registeredCount > 0) {
|
||||
event.registeredCount -= 1;
|
||||
await event.save();
|
||||
}
|
||||
|
||||
// TODO: In a real implementation, we would also remove the user's registration
|
||||
// from a separate EventRegistration collection
|
||||
|
||||
return res.status(200).json({
|
||||
message: 'Successfully cancelled registration',
|
||||
event
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error cancelling registration:', error);
|
||||
return res.status(500).json({
|
||||
error: {
|
||||
message: 'Failed to cancel registration'
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -9,11 +9,11 @@ import { rateLimit } from 'express-rate-limit';
|
|||
// Load environment variables
|
||||
dotenv.config();
|
||||
|
||||
// Import routes (will be implemented later)
|
||||
// Import routes
|
||||
// import authRoutes from './routes/auth';
|
||||
// import eventRoutes from './routes/events';
|
||||
import eventRoutes from './routes/events';
|
||||
// import memberRoutes from './routes/members';
|
||||
// import videoRoutes from './routes/videos';
|
||||
import videoRoutes from './routes/videos';
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
|
@ -64,9 +64,9 @@ app.get('/health', (req, res) => {
|
|||
|
||||
// API routes
|
||||
// app.use('/api/auth', authRoutes);
|
||||
// app.use('/api/events', eventRoutes);
|
||||
app.use('/api/events', eventRoutes);
|
||||
// app.use('/api/members', memberRoutes);
|
||||
// app.use('/api/videos', videoRoutes);
|
||||
app.use('/api/videos', videoRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
||||
|
|
|
|||
97
backend/src/models/Event.ts
Normal file
97
backend/src/models/Event.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import mongoose, { Schema, Document } from 'mongoose';
|
||||
|
||||
// Event document interface
|
||||
export interface IEvent extends Document {
|
||||
title: string;
|
||||
description: string;
|
||||
date: Date;
|
||||
time: string;
|
||||
location: string;
|
||||
category: 'social' | 'athletic' | 'board' | 'general' | 'educational';
|
||||
status: 'published' | 'draft' | 'archived';
|
||||
registrationRequired: boolean;
|
||||
registeredCount: number;
|
||||
maxAttendees?: number;
|
||||
recurring?: boolean;
|
||||
recurrencePattern?: string;
|
||||
organizer?: mongoose.Types.ObjectId;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
// Event schema
|
||||
const EventSchema: Schema = new Schema({
|
||||
title: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
minLength: 3
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
date: {
|
||||
type: Date,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
time: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
location: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true
|
||||
},
|
||||
category: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['social', 'athletic', 'board', 'general', 'educational'],
|
||||
index: true
|
||||
},
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
enum: ['published', 'draft', 'archived'],
|
||||
default: 'draft',
|
||||
index: true
|
||||
},
|
||||
registrationRequired: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
registeredCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
maxAttendees: {
|
||||
type: Number
|
||||
},
|
||||
recurring: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
recurrencePattern: {
|
||||
type: String
|
||||
},
|
||||
organizer: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'User'
|
||||
},
|
||||
tags: [{
|
||||
type: String,
|
||||
trim: true
|
||||
}]
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Create indexes for performance
|
||||
EventSchema.index({ title: 'text', description: 'text', location: 'text' }); // Full-text search
|
||||
EventSchema.index({ date: 1, category: 1 }); // Date and category filtering
|
||||
EventSchema.index({ status: 1, date: 1 }); // Status and date filtering
|
||||
|
||||
export default mongoose.model<IEvent>('Event', EventSchema);
|
||||
20
backend/src/routes/events.ts
Normal file
20
backend/src/routes/events.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import express from 'express';
|
||||
import { checkAuth } from '../middleware/auth';
|
||||
import * as eventController from '../controllers/eventController';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Public routes
|
||||
router.get('/', eventController.getEvents);
|
||||
router.get('/:id', eventController.getEventById);
|
||||
|
||||
// Protected routes (admin only)
|
||||
router.post('/', checkAuth, eventController.createEvent);
|
||||
router.put('/:id', checkAuth, eventController.updateEvent);
|
||||
router.delete('/:id', checkAuth, eventController.deleteEvent);
|
||||
|
||||
// Registration routes
|
||||
router.post('/:id/register', checkAuth, eventController.registerForEvent);
|
||||
router.post('/:id/cancel-registration', checkAuth, eventController.cancelRegistration);
|
||||
|
||||
export default router;
|
||||
|
|
@ -17,6 +17,16 @@
|
|||
- Created content management with WYSIWYG editor preview
|
||||
- Added settings interface with site, email, and membership options
|
||||
|
||||
- Implemented full Events Management System:
|
||||
- Created Event model and API endpoints in the backend
|
||||
- Implemented EventController with CRUD operations
|
||||
- Built useEvents hook for frontend data management
|
||||
- Updated events listing page to use real data with filtering
|
||||
- Created event creation form with validation
|
||||
- Implemented event editing functionality
|
||||
- Added event details view page
|
||||
- Implemented comprehensive test suite for Events API
|
||||
|
||||
- Major improvements to frontend architecture:
|
||||
- Implemented responsive layouts for all admin interfaces
|
||||
- Used TypeScript for strongly-typed components
|
||||
|
|
@ -33,13 +43,15 @@
|
|||
|
||||
## Next Steps
|
||||
1. Complete backend API integrations:
|
||||
- Connect admin UI components to backend endpoints
|
||||
- Add real data loading with loading states
|
||||
- Implement error handling for API requests
|
||||
- Set up client-side data validation
|
||||
- Connect admin UI components to backend endpoints (✓ Events management implemented)
|
||||
- Add real data loading with loading states (✓ Implemented for Events)
|
||||
- Implement error handling for API requests (✓ Implemented for Events)
|
||||
- Set up client-side data validation (✓ Implemented for Events)
|
||||
2. Implement remaining admin features:
|
||||
- Add form handlers for CRUD operations
|
||||
- Implement event recurrence functionality
|
||||
- Add form handlers for CRUD operations (✓ Implemented for Events)
|
||||
- Connect Members management to backend API
|
||||
- Connect Videos management to backend API
|
||||
- Connect Documents management to backend API
|
||||
- Create media upload components
|
||||
- Add user notification system
|
||||
3. Deploy staging environment:
|
||||
|
|
|
|||
|
|
@ -64,10 +64,15 @@
|
|||
## In Progress Features
|
||||
|
||||
### Phase 4: API Integration & Backend Functionality
|
||||
- [ ] Connect admin interfaces to backend API endpoints
|
||||
- [ ] Implement real data loading with state management
|
||||
- [ ] Create API error handling and recovery
|
||||
- [ ] Develop form submissions with validation
|
||||
- [x] Connect admin interfaces to backend API endpoints (Events management)
|
||||
- [x] Implement real data loading with state management (Events)
|
||||
- [x] Create API error handling and recovery (Events)
|
||||
- [x] Develop form submissions with validation (Events)
|
||||
- [x] Implement event creation and editing forms
|
||||
- [x] Create event details view page
|
||||
- [x] Add event deletion with confirmation
|
||||
- [x] Implement comprehensive test suite for API endpoints
|
||||
- [ ] Connect remaining admin interfaces to backend APIs
|
||||
- [ ] Build media upload functionality
|
||||
- [ ] Implement user notification system
|
||||
- [ ] Create advanced filtering for data tables
|
||||
|
|
@ -122,9 +127,9 @@
|
|||
- [ ] Add comprehensive JSDoc comments
|
||||
|
||||
## Project Stats
|
||||
- **Completed Tasks:** 36
|
||||
- **Completed Tasks:** 44
|
||||
- **In Progress Tasks:** 9
|
||||
- **Upcoming Tasks:** 23
|
||||
- **Completion Rate:** ~53%
|
||||
- **Completion Rate:** ~58%
|
||||
- **Current Phase:** Transitioning from Phase 3 to Phase 4
|
||||
- **Next Major Milestone:** Full API integration
|
||||
|
|
|
|||
|
|
@ -76,6 +76,25 @@
|
|||
- Error association
|
||||
- Keyboard navigation
|
||||
|
||||
### Admin Form Patterns
|
||||
- **Create/Edit/View pattern**
|
||||
- Consistent page structure for entity management
|
||||
- Shared form components between create and edit
|
||||
- Read-only view pages with formatted data display
|
||||
- **Form state management**
|
||||
- Local state for form data
|
||||
- Separate state for validation errors
|
||||
- Loading and submission states
|
||||
- **Validation strategies**
|
||||
- Client-side validation before submission
|
||||
- Field-level validation on change/blur
|
||||
- Form-level validation on submit
|
||||
- Server-side validation as fallback
|
||||
- **Error recovery**
|
||||
- Clear error messages
|
||||
- Preservation of valid form data
|
||||
- Ability to retry submission
|
||||
|
||||
### Routing
|
||||
- **Next.js App Router**
|
||||
- File-based routing for pages
|
||||
|
|
@ -100,6 +119,21 @@
|
|||
- Services for reusable operations
|
||||
- Models for data access
|
||||
|
||||
### API Integration Patterns
|
||||
- **Custom hooks for API interaction**
|
||||
- Resource-specific hooks (useEvents, useMembers, etc.)
|
||||
- Standardized CRUD operations
|
||||
- Loading and error states
|
||||
- Pagination and filtering support
|
||||
- **Data transformation**
|
||||
- Server-to-client format conversion
|
||||
- Date formatting for display
|
||||
- Consistent error handling
|
||||
- **Optimistic updates**
|
||||
- Update UI before server confirmation
|
||||
- Rollback on error
|
||||
- Loading indicators during operations
|
||||
|
||||
### Authentication
|
||||
- **JWT-based authentication**
|
||||
- JWTs stored in HTTP-only cookies
|
||||
|
|
@ -163,6 +197,13 @@
|
|||
- Create, read, update, delete operations
|
||||
- Schema validation
|
||||
- Indexing performance
|
||||
- **Admin API testing patterns**
|
||||
- In-memory MongoDB for isolated tests
|
||||
- Mock authentication middleware
|
||||
- Comprehensive CRUD operation testing
|
||||
- Filter and pagination testing
|
||||
- Error case testing
|
||||
- Registration functionality testing
|
||||
|
||||
### End-to-End Testing
|
||||
- **User flow testing**
|
||||
|
|
|
|||
602
frontend/src/app/admin/events/[id]/edit/page.tsx
Normal file
602
frontend/src/app/admin/events/[id]/edit/page.tsx
Normal file
|
|
@ -0,0 +1,602 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import { useEvents, Event } from '../../../../../hooks/useEvents';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface EventFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
category: 'social' | 'athletic' | 'board' | 'general' | 'educational';
|
||||
status: 'published' | 'draft' | 'archived';
|
||||
registrationRequired: boolean;
|
||||
maxAttendees?: number;
|
||||
recurring: boolean;
|
||||
recurrencePattern?: string;
|
||||
tags: string;
|
||||
registeredCount: number;
|
||||
}
|
||||
|
||||
interface EventFormErrors {
|
||||
title?: string;
|
||||
description?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
maxAttendees?: string;
|
||||
recurrencePattern?: string;
|
||||
}
|
||||
|
||||
export default function EditEventPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const eventId = params.id as string;
|
||||
|
||||
const { getEventById, updateEvent } = useEvents();
|
||||
|
||||
const [formData, setFormData] = useState<EventFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
time: '',
|
||||
location: '',
|
||||
category: 'social',
|
||||
status: 'draft',
|
||||
registrationRequired: false,
|
||||
maxAttendees: undefined,
|
||||
recurring: false,
|
||||
recurrencePattern: '',
|
||||
tags: '',
|
||||
registeredCount: 0,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<EventFormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState('');
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
// Load event data
|
||||
useEffect(() => {
|
||||
const loadEvent = async () => {
|
||||
setIsLoading(true);
|
||||
setLoadError('');
|
||||
|
||||
try {
|
||||
const event = await getEventById(eventId);
|
||||
|
||||
if (event) {
|
||||
// Format date for input field (YYYY-MM-DD)
|
||||
const dateObj = new Date(event.date);
|
||||
const formattedDate = dateObj.toISOString().split('T')[0];
|
||||
|
||||
// Convert tags array to comma-separated string
|
||||
const tagsString = event.tags?.join(', ') || '';
|
||||
|
||||
setFormData({
|
||||
title: event.title,
|
||||
description: event.description,
|
||||
date: formattedDate,
|
||||
time: event.time,
|
||||
location: event.location,
|
||||
category: event.category,
|
||||
status: event.status,
|
||||
registrationRequired: event.registrationRequired,
|
||||
maxAttendees: event.maxAttendees,
|
||||
recurring: event.recurring || false,
|
||||
recurrencePattern: event.recurrencePattern || '',
|
||||
tags: tagsString,
|
||||
registeredCount: event.registeredCount,
|
||||
});
|
||||
} else {
|
||||
setLoadError('Event not found');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading event:', error);
|
||||
setLoadError('Failed to load event data. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEvent();
|
||||
}, [eventId, getEventById]);
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: EventFormErrors = {};
|
||||
|
||||
// Title validation
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
} else if (formData.title.length < 3) {
|
||||
newErrors.title = 'Title must be at least 3 characters';
|
||||
}
|
||||
|
||||
// Description validation
|
||||
if (!formData.description.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
}
|
||||
|
||||
// Date validation
|
||||
if (!formData.date) {
|
||||
newErrors.date = 'Date is required';
|
||||
}
|
||||
|
||||
// Time validation
|
||||
if (!formData.time.trim()) {
|
||||
newErrors.time = 'Time is required';
|
||||
}
|
||||
|
||||
// Location validation
|
||||
if (!formData.location.trim()) {
|
||||
newErrors.location = 'Location is required';
|
||||
}
|
||||
|
||||
// Category validation
|
||||
if (!formData.category) {
|
||||
newErrors.category = 'Category is required';
|
||||
}
|
||||
|
||||
// Status validation
|
||||
if (!formData.status) {
|
||||
newErrors.status = 'Status is required';
|
||||
}
|
||||
|
||||
// Max attendees validation (if registration is required)
|
||||
if (formData.registrationRequired && formData.maxAttendees !== undefined) {
|
||||
if (formData.maxAttendees <= 0) {
|
||||
newErrors.maxAttendees = 'Maximum attendees must be greater than 0';
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence pattern validation (if event is recurring)
|
||||
if (formData.recurring && !formData.recurrencePattern?.trim()) {
|
||||
newErrors.recurrencePattern = 'Recurrence pattern is required for recurring events';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target as HTMLInputElement;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: checked,
|
||||
});
|
||||
} else if (name === 'maxAttendees') {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value ? parseInt(value, 10) : undefined,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear error when user types
|
||||
if (errors[name as keyof EventFormErrors]) {
|
||||
setErrors({
|
||||
...errors,
|
||||
[name]: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError('');
|
||||
|
||||
try {
|
||||
// Convert form data to the format expected by the API
|
||||
const eventData = {
|
||||
...formData,
|
||||
tags: formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag),
|
||||
date: new Date(formData.date).toISOString(),
|
||||
};
|
||||
|
||||
const result = await updateEvent(eventId, eventData);
|
||||
|
||||
if (result) {
|
||||
// Redirect to events list page on success
|
||||
router.push('/admin/events');
|
||||
} else {
|
||||
setSubmitError('Failed to update event. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating event:', error);
|
||||
setSubmitError('An unexpected error occurred. Please try again later.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<span className="ml-3 text-lg">Loading event data...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 px-4 py-3 rounded mb-6" role="alert">
|
||||
<p className="font-medium">Error</p>
|
||||
<p>{loadError}</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/admin/events" className="btn-primary">
|
||||
Return to Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Edit Event</h1>
|
||||
<Link
|
||||
href="/admin/events"
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
{/* Error message */}
|
||||
{submitError && (
|
||||
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 px-4 py-3 rounded mb-6" role="alert">
|
||||
<p className="font-medium">Error</p>
|
||||
<p>{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title field */}
|
||||
<div>
|
||||
<label htmlFor="title" className="form-label">
|
||||
Title <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.title ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Event title"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.title}
|
||||
aria-describedby={errors.title ? 'title-error' : undefined}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="title-error">
|
||||
{errors.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div>
|
||||
<label htmlFor="description" className="form-label">
|
||||
Description <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className={`form-input w-full ${errors.description ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Event description"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.description}
|
||||
aria-describedby={errors.description ? 'description-error' : undefined}
|
||||
></textarea>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="description-error">
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date and Time fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="date" className="form-label">
|
||||
Date <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.date ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.date}
|
||||
aria-describedby={errors.date ? 'date-error' : undefined}
|
||||
/>
|
||||
{errors.date && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="date-error">
|
||||
{errors.date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="form-label">
|
||||
Time <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="time"
|
||||
name="time"
|
||||
value={formData.time}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.time ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="e.g., 6:00 PM - 9:00 PM"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.time}
|
||||
aria-describedby={errors.time ? 'time-error' : undefined}
|
||||
/>
|
||||
{errors.time && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="time-error">
|
||||
{errors.time}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div>
|
||||
<label htmlFor="location" className="form-label">
|
||||
Location <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.location ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Event location"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.location}
|
||||
aria-describedby={errors.location ? 'location-error' : undefined}
|
||||
/>
|
||||
{errors.location && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="location-error">
|
||||
{errors.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category and Status fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="category" className="form-label">
|
||||
Category <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.category ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.category}
|
||||
aria-describedby={errors.category ? 'category-error' : undefined}
|
||||
>
|
||||
<option value="social">Social</option>
|
||||
<option value="athletic">Athletic</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="general">General</option>
|
||||
<option value="educational">Educational</option>
|
||||
</select>
|
||||
{errors.category && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="category-error">
|
||||
{errors.category}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="status" className="form-label">
|
||||
Status <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.status ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.status}
|
||||
aria-describedby={errors.status ? 'status-error' : undefined}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
{errors.status && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="status-error">
|
||||
{errors.status}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="registrationRequired"
|
||||
name="registrationRequired"
|
||||
checked={formData.registrationRequired}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="registrationRequired" className="ml-2 block text-sm text-gray-900 dark:text-gray-100">
|
||||
Registration required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.registrationRequired && (
|
||||
<div>
|
||||
<label htmlFor="maxAttendees" className="form-label">
|
||||
Maximum Attendees
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxAttendees"
|
||||
name="maxAttendees"
|
||||
value={formData.maxAttendees || ''}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
className={`form-input w-full ${errors.maxAttendees ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Leave blank for unlimited"
|
||||
aria-invalid={!!errors.maxAttendees}
|
||||
aria-describedby={errors.maxAttendees ? 'maxAttendees-error' : undefined}
|
||||
/>
|
||||
{errors.maxAttendees && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="maxAttendees-error">
|
||||
{errors.maxAttendees}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{formData.registrationRequired && (
|
||||
<div>
|
||||
<label htmlFor="registeredCount" className="form-label">
|
||||
Current Registrations
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="registeredCount"
|
||||
name="registeredCount"
|
||||
value={formData.registeredCount}
|
||||
onChange={handleChange}
|
||||
min="0"
|
||||
className="form-input w-full"
|
||||
aria-describedby="registeredCount-help"
|
||||
/>
|
||||
<p id="registeredCount-help" className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Number of people currently registered for this event.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recurring event fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="recurring"
|
||||
name="recurring"
|
||||
checked={formData.recurring}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="recurring" className="ml-2 block text-sm text-gray-900 dark:text-gray-100">
|
||||
Recurring event
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.recurring && (
|
||||
<div>
|
||||
<label htmlFor="recurrencePattern" className="form-label">
|
||||
Recurrence Pattern <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="recurrencePattern"
|
||||
name="recurrencePattern"
|
||||
value={formData.recurrencePattern || ''}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.recurrencePattern ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="e.g., Weekly on Tuesdays, Monthly on first Monday"
|
||||
aria-required={formData.recurring}
|
||||
aria-invalid={!!errors.recurrencePattern}
|
||||
aria-describedby={errors.recurrencePattern ? 'recurrencePattern-error' : undefined}
|
||||
/>
|
||||
{errors.recurrencePattern && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="recurrencePattern-error">
|
||||
{errors.recurrencePattern}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags field */}
|
||||
<div>
|
||||
<label htmlFor="tags" className="form-label">
|
||||
Tags (comma separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
name="tags"
|
||||
value={formData.tags}
|
||||
onChange={handleChange}
|
||||
className="form-input w-full"
|
||||
placeholder="e.g., social, family-friendly, outdoor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Link
|
||||
href="/admin/events"
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-red-600">*</span> Required fields
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
265
frontend/src/app/admin/events/[id]/page.tsx
Normal file
265
frontend/src/app/admin/events/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useEvents } from '../../../../hooks/useEvents';
|
||||
|
||||
export default function EventDetailsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const eventId = params.id as string;
|
||||
|
||||
const { getEventById, deleteEvent } = useEvents();
|
||||
|
||||
const [event, setEvent] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const loadEvent = async () => {
|
||||
setIsLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const eventData = await getEventById(eventId);
|
||||
|
||||
if (eventData) {
|
||||
setEvent(eventData);
|
||||
} else {
|
||||
setError('Event not found');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading event:', err);
|
||||
setError('Failed to load event data. Please try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadEvent();
|
||||
}, [eventId, getEventById]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!window.confirm('Are you sure you want to delete this event?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsDeleting(true);
|
||||
|
||||
try {
|
||||
const success = await deleteEvent(eventId);
|
||||
|
||||
if (success) {
|
||||
router.push('/admin/events');
|
||||
} else {
|
||||
setError('Failed to delete event. Please try again.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting event:', err);
|
||||
setError('An unexpected error occurred. Please try again later.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary"></div>
|
||||
<span className="ml-3 text-lg">Loading event data...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 px-4 py-3 rounded mb-6" role="alert">
|
||||
<p className="font-medium">Error</p>
|
||||
<p>{error}</p>
|
||||
<div className="mt-4">
|
||||
<Link href="/admin/events" className="btn-primary">
|
||||
Return to Events
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
weekday: 'long',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">{event.title}</h1>
|
||||
<div className="flex space-x-3">
|
||||
<Link
|
||||
href="/admin/events"
|
||||
className="btn-secondary"
|
||||
>
|
||||
Back to Events
|
||||
</Link>
|
||||
<Link
|
||||
href={`/admin/events/${eventId}/edit`}
|
||||
className="btn-primary"
|
||||
>
|
||||
Edit Event
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card p-6 mb-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="md:col-span-2 space-y-6">
|
||||
{/* Event details */}
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Event Details</h2>
|
||||
|
||||
<div className="prose dark:prose-invert max-w-none">
|
||||
<p>{event.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{event.tags && event.tags.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Tags</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.tags.map((tag: string, index: number) => (
|
||||
<span
|
||||
key={index}
|
||||
className="bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-200 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recurring info */}
|
||||
{event.recurring && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-2">Recurrence</h3>
|
||||
<p>{event.recurrencePattern}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Event metadata */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-4">Event Information</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Date</p>
|
||||
<p className="font-medium">{formatDate(event.date)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Time</p>
|
||||
<p className="font-medium">{event.time}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Location</p>
|
||||
<p className="font-medium">{event.location}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Category</p>
|
||||
<p className="font-medium capitalize">{event.category}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Status</p>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
|
||||
event.status === 'published' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
|
||||
event.status === 'draft' ? 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200' :
|
||||
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||
}`}>
|
||||
{event.status.charAt(0).toUpperCase() + event.status.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
|
||||
<h3 className="text-lg font-semibold mb-4">Registration</h3>
|
||||
|
||||
{event.registrationRequired ? (
|
||||
<div className="space-y-3">
|
||||
<p>Registration is required for this event.</p>
|
||||
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Current Registrations</p>
|
||||
<p className="font-medium">{event.registeredCount}</p>
|
||||
</div>
|
||||
|
||||
{event.maxAttendees && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Maximum Attendees</p>
|
||||
<p className="font-medium">{event.maxAttendees}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.maxAttendees && (
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5">
|
||||
<div
|
||||
className="bg-primary h-2.5 rounded-full"
|
||||
style={{ width: `${Math.min(100, (event.registeredCount / event.maxAttendees) * 100)}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{event.registeredCount} of {event.maxAttendees} spots filled
|
||||
({Math.round((event.registeredCount / event.maxAttendees) * 100)}%)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>Registration is not required for this event.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Danger zone */}
|
||||
<div className="border border-red-300 dark:border-red-700 rounded-lg p-4">
|
||||
<h3 className="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Deleting this event will permanently remove it and cannot be undone.
|
||||
</p>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white py-2 px-4 rounded focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:opacity-50"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Event'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
510
frontend/src/app/admin/events/create/page.tsx
Normal file
510
frontend/src/app/admin/events/create/page.tsx
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEvents } from '../../../../hooks/useEvents';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface EventFormData {
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
category: 'social' | 'athletic' | 'board' | 'general' | 'educational';
|
||||
status: 'published' | 'draft' | 'archived';
|
||||
registrationRequired: boolean;
|
||||
maxAttendees?: number;
|
||||
recurring: boolean;
|
||||
recurrencePattern?: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
interface EventFormErrors {
|
||||
title?: string;
|
||||
description?: string;
|
||||
date?: string;
|
||||
time?: string;
|
||||
location?: string;
|
||||
category?: string;
|
||||
status?: string;
|
||||
maxAttendees?: string;
|
||||
recurrencePattern?: string;
|
||||
}
|
||||
|
||||
export default function CreateEventPage() {
|
||||
const router = useRouter();
|
||||
const { createEvent } = useEvents();
|
||||
|
||||
const [formData, setFormData] = useState<EventFormData>({
|
||||
title: '',
|
||||
description: '',
|
||||
date: '',
|
||||
time: '',
|
||||
location: '',
|
||||
category: 'social',
|
||||
status: 'draft',
|
||||
registrationRequired: false,
|
||||
maxAttendees: undefined,
|
||||
recurring: false,
|
||||
recurrencePattern: '',
|
||||
tags: '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<EventFormErrors>({});
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [submitError, setSubmitError] = useState('');
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: EventFormErrors = {};
|
||||
|
||||
// Title validation
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = 'Title is required';
|
||||
} else if (formData.title.length < 3) {
|
||||
newErrors.title = 'Title must be at least 3 characters';
|
||||
}
|
||||
|
||||
// Description validation
|
||||
if (!formData.description.trim()) {
|
||||
newErrors.description = 'Description is required';
|
||||
}
|
||||
|
||||
// Date validation
|
||||
if (!formData.date) {
|
||||
newErrors.date = 'Date is required';
|
||||
} else {
|
||||
const selectedDate = new Date(formData.date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
if (selectedDate < today) {
|
||||
newErrors.date = 'Date cannot be in the past';
|
||||
}
|
||||
}
|
||||
|
||||
// Time validation
|
||||
if (!formData.time.trim()) {
|
||||
newErrors.time = 'Time is required';
|
||||
}
|
||||
|
||||
// Location validation
|
||||
if (!formData.location.trim()) {
|
||||
newErrors.location = 'Location is required';
|
||||
}
|
||||
|
||||
// Category validation
|
||||
if (!formData.category) {
|
||||
newErrors.category = 'Category is required';
|
||||
}
|
||||
|
||||
// Status validation
|
||||
if (!formData.status) {
|
||||
newErrors.status = 'Status is required';
|
||||
}
|
||||
|
||||
// Max attendees validation (if registration is required)
|
||||
if (formData.registrationRequired && formData.maxAttendees !== undefined) {
|
||||
if (formData.maxAttendees <= 0) {
|
||||
newErrors.maxAttendees = 'Maximum attendees must be greater than 0';
|
||||
}
|
||||
}
|
||||
|
||||
// Recurrence pattern validation (if event is recurring)
|
||||
if (formData.recurring && !formData.recurrencePattern?.trim()) {
|
||||
newErrors.recurrencePattern = 'Recurrence pattern is required for recurring events';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target as HTMLInputElement;
|
||||
|
||||
if (type === 'checkbox') {
|
||||
const checked = (e.target as HTMLInputElement).checked;
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: checked,
|
||||
});
|
||||
} else if (name === 'maxAttendees') {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value ? parseInt(value, 10) : undefined,
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
...formData,
|
||||
[name]: value,
|
||||
});
|
||||
}
|
||||
|
||||
// Clear error when user types
|
||||
if (errors[name as keyof EventFormErrors]) {
|
||||
setErrors({
|
||||
...errors,
|
||||
[name]: undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setSubmitError('');
|
||||
|
||||
try {
|
||||
// Convert form data to the format expected by the API
|
||||
const eventData = {
|
||||
...formData,
|
||||
tags: formData.tags.split(',').map(tag => tag.trim()).filter(tag => tag),
|
||||
date: new Date(formData.date).toISOString(),
|
||||
registeredCount: 0, // Initialize with zero registrations
|
||||
};
|
||||
|
||||
const result = await createEvent(eventData);
|
||||
|
||||
if (result) {
|
||||
// Redirect to events list page on success
|
||||
router.push('/admin/events');
|
||||
} else {
|
||||
setSubmitError('Failed to create event. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating event:', error);
|
||||
setSubmitError('An unexpected error occurred. Please try again later.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h1 className="text-2xl font-bold">Create New Event</h1>
|
||||
<Link
|
||||
href="/admin/events"
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="card p-6">
|
||||
{/* Error message */}
|
||||
{submitError && (
|
||||
<div className="bg-red-100 dark:bg-red-900 border border-red-400 dark:border-red-600 text-red-700 dark:text-red-200 px-4 py-3 rounded mb-6" role="alert">
|
||||
<p className="font-medium">Error</p>
|
||||
<p>{submitError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Title field */}
|
||||
<div>
|
||||
<label htmlFor="title" className="form-label">
|
||||
Title <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.title ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Event title"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.title}
|
||||
aria-describedby={errors.title ? 'title-error' : undefined}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="title-error">
|
||||
{errors.title}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description field */}
|
||||
<div>
|
||||
<label htmlFor="description" className="form-label">
|
||||
Description <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
rows={4}
|
||||
className={`form-input w-full ${errors.description ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Event description"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.description}
|
||||
aria-describedby={errors.description ? 'description-error' : undefined}
|
||||
></textarea>
|
||||
{errors.description && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="description-error">
|
||||
{errors.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date and Time fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="date" className="form-label">
|
||||
Date <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="date"
|
||||
name="date"
|
||||
value={formData.date}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.date ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.date}
|
||||
aria-describedby={errors.date ? 'date-error' : undefined}
|
||||
/>
|
||||
{errors.date && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="date-error">
|
||||
{errors.date}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="time" className="form-label">
|
||||
Time <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="time"
|
||||
name="time"
|
||||
value={formData.time}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.time ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="e.g., 6:00 PM - 9:00 PM"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.time}
|
||||
aria-describedby={errors.time ? 'time-error' : undefined}
|
||||
/>
|
||||
{errors.time && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="time-error">
|
||||
{errors.time}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div>
|
||||
<label htmlFor="location" className="form-label">
|
||||
Location <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="location"
|
||||
name="location"
|
||||
value={formData.location}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.location ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Event location"
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.location}
|
||||
aria-describedby={errors.location ? 'location-error' : undefined}
|
||||
/>
|
||||
{errors.location && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="location-error">
|
||||
{errors.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Category and Status fields */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label htmlFor="category" className="form-label">
|
||||
Category <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.category ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.category}
|
||||
aria-describedby={errors.category ? 'category-error' : undefined}
|
||||
>
|
||||
<option value="social">Social</option>
|
||||
<option value="athletic">Athletic</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="general">General</option>
|
||||
<option value="educational">Educational</option>
|
||||
</select>
|
||||
{errors.category && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="category-error">
|
||||
{errors.category}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="status" className="form-label">
|
||||
Status <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="status"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.status ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
aria-required="true"
|
||||
aria-invalid={!!errors.status}
|
||||
aria-describedby={errors.status ? 'status-error' : undefined}
|
||||
>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published">Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
{errors.status && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="status-error">
|
||||
{errors.status}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Registration fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="registrationRequired"
|
||||
name="registrationRequired"
|
||||
checked={formData.registrationRequired}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="registrationRequired" className="ml-2 block text-sm text-gray-900 dark:text-gray-100">
|
||||
Registration required
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.registrationRequired && (
|
||||
<div>
|
||||
<label htmlFor="maxAttendees" className="form-label">
|
||||
Maximum Attendees
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxAttendees"
|
||||
name="maxAttendees"
|
||||
value={formData.maxAttendees || ''}
|
||||
onChange={handleChange}
|
||||
min="1"
|
||||
className={`form-input w-full ${errors.maxAttendees ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="Leave blank for unlimited"
|
||||
aria-invalid={!!errors.maxAttendees}
|
||||
aria-describedby={errors.maxAttendees ? 'maxAttendees-error' : undefined}
|
||||
/>
|
||||
{errors.maxAttendees && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="maxAttendees-error">
|
||||
{errors.maxAttendees}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Recurring event fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="recurring"
|
||||
name="recurring"
|
||||
checked={formData.recurring}
|
||||
onChange={handleChange}
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<label htmlFor="recurring" className="ml-2 block text-sm text-gray-900 dark:text-gray-100">
|
||||
Recurring event
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{formData.recurring && (
|
||||
<div>
|
||||
<label htmlFor="recurrencePattern" className="form-label">
|
||||
Recurrence Pattern <span className="text-red-600">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="recurrencePattern"
|
||||
name="recurrencePattern"
|
||||
value={formData.recurrencePattern || ''}
|
||||
onChange={handleChange}
|
||||
className={`form-input w-full ${errors.recurrencePattern ? 'border-red-500 dark:border-red-400' : ''}`}
|
||||
placeholder="e.g., Weekly on Tuesdays, Monthly on first Monday"
|
||||
aria-required={formData.recurring}
|
||||
aria-invalid={!!errors.recurrencePattern}
|
||||
aria-describedby={errors.recurrencePattern ? 'recurrencePattern-error' : undefined}
|
||||
/>
|
||||
{errors.recurrencePattern && (
|
||||
<p className="mt-1 text-red-600 dark:text-red-400 text-sm" id="recurrencePattern-error">
|
||||
{errors.recurrencePattern}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags field */}
|
||||
<div>
|
||||
<label htmlFor="tags" className="form-label">
|
||||
Tags (comma separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
name="tags"
|
||||
value={formData.tags}
|
||||
onChange={handleChange}
|
||||
className="form-input w-full"
|
||||
placeholder="e.g., social, family-friendly, outdoor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit button */}
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<Link
|
||||
href="/admin/events"
|
||||
className="btn-secondary"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="btn-primary"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Event'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-red-600">*</span> Required fields
|
||||
</p>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useEvents, Event as EventType, EventFilter as EventFilterType } from '../../../hooks/useEvents';
|
||||
|
||||
// Simple Filter Component
|
||||
const EventFilter = ({
|
||||
|
|
@ -60,6 +61,7 @@ const EventFilter = ({
|
|||
<option value="athletic">Athletic</option>
|
||||
<option value="board">Board</option>
|
||||
<option value="general">General</option>
|
||||
<option value="educational">Educational</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
|
@ -101,84 +103,44 @@ const EventFilter = ({
|
|||
export default function EventsPage() {
|
||||
const [filter, setFilter] = useState({ status: 'all', category: 'all', search: '' });
|
||||
|
||||
// Mock events data - would come from API in a real implementation
|
||||
const allEvents = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Monthly Social',
|
||||
date: 'Apr 15, 2025',
|
||||
time: '6:00 PM - 9:00 PM',
|
||||
location: 'Olathe Community Center',
|
||||
category: 'social',
|
||||
status: 'published',
|
||||
registrationRequired: true,
|
||||
registeredCount: 24
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'ASL Workshop',
|
||||
date: 'Apr 22, 2025',
|
||||
time: '10:00 AM - 12:00 PM',
|
||||
location: 'OCD Meeting Room',
|
||||
category: 'educational',
|
||||
status: 'draft',
|
||||
registrationRequired: true,
|
||||
registeredCount: 12
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Board Meeting',
|
||||
date: 'May 5, 2025',
|
||||
time: '7:00 PM - 8:30 PM',
|
||||
location: 'Zoom Meeting',
|
||||
category: 'board',
|
||||
status: 'published',
|
||||
registrationRequired: false,
|
||||
registeredCount: 0
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Summer Picnic',
|
||||
date: 'Jun 12, 2025',
|
||||
time: '12:00 PM - 4:00 PM',
|
||||
location: 'Olathe Park',
|
||||
category: 'social',
|
||||
status: 'draft',
|
||||
registrationRequired: true,
|
||||
registeredCount: 0
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Deaf Awareness Week Workshop',
|
||||
date: 'Sep 23, 2025',
|
||||
time: '6:30 PM - 8:30 PM',
|
||||
location: 'Olathe Public Library',
|
||||
category: 'educational',
|
||||
status: 'published',
|
||||
registrationRequired: true,
|
||||
registeredCount: 8
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Holiday Party',
|
||||
date: 'Dec 18, 2025',
|
||||
time: '7:00 PM - 10:00 PM',
|
||||
location: 'Olathe Community Center',
|
||||
category: 'social',
|
||||
status: 'draft',
|
||||
registrationRequired: true,
|
||||
registeredCount: 0
|
||||
},
|
||||
];
|
||||
// Use the events hook to fetch and manage events
|
||||
const {
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchEvents,
|
||||
deleteEvent
|
||||
} = useEvents();
|
||||
|
||||
// Filter events based on current filter settings
|
||||
const filteredEvents = allEvents.filter(event => {
|
||||
if (filter.status !== 'all' && event.status !== filter.status) return false;
|
||||
if (filter.category !== 'all' && event.category !== filter.category) return false;
|
||||
if (filter.search && !event.title.toLowerCase().includes(filter.search.toLowerCase()) &&
|
||||
!event.location.toLowerCase().includes(filter.search.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
// Apply filters when the filter state changes
|
||||
useEffect(() => {
|
||||
const apiFilter: EventFilterType = {};
|
||||
|
||||
if (filter.status !== 'all') {
|
||||
apiFilter.status = filter.status;
|
||||
}
|
||||
|
||||
if (filter.category !== 'all') {
|
||||
apiFilter.category = filter.category;
|
||||
}
|
||||
|
||||
if (filter.search) {
|
||||
apiFilter.search = filter.search;
|
||||
}
|
||||
|
||||
fetchEvents(apiFilter);
|
||||
}, [filter, fetchEvents]);
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilter: { status: string; category: string; search: string }) => {
|
||||
|
|
@ -267,65 +229,96 @@ export default function EventsPage() {
|
|||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-800">
|
||||
{filteredEvents.map((event) => (
|
||||
<tr key={event.id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">{event.title}</div>
|
||||
<div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getCategoryBadgeClasses(event.category)}`}>
|
||||
{event.category.charAt(0).toUpperCase() + event.category.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{event.date}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{event.time}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{event.location}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClasses(event.status)}`}>
|
||||
{event.status.charAt(0).toUpperCase() + event.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{event.registrationRequired ? (
|
||||
<span>{event.registeredCount} registered</span>
|
||||
) : (
|
||||
<span>Not required</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/events/${event.id}`} className="text-primary hover:text-primary-dark">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/events/${event.id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Delete functionality would go here
|
||||
alert(`Delete event: ${event.title}`);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center">
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
<p className="mt-2 text-gray-500 dark:text-gray-400">Loading events...</p>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center">
|
||||
<p className="text-red-500 dark:text-red-400">Error loading events. Please try again.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : events.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No events found matching the current filters.</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
events.map((event) => (
|
||||
<tr key={event._id}>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{event.title}
|
||||
</div>
|
||||
<div>
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getCategoryBadgeClasses(event.category)}`}>
|
||||
{event.category.charAt(0).toUpperCase() + event.category.slice(1)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{formatDate(event.date)}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{event.time}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900 dark:text-white">{event.location}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusBadgeClasses(event.status)}`}>
|
||||
{event.status.charAt(0).toUpperCase() + event.status.slice(1)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
{event.registrationRequired ? (
|
||||
<span>{event.registeredCount} registered</span>
|
||||
) : (
|
||||
<span>Not required</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Link href={`/admin/events/${event._id}`} className="text-primary hover:text-primary-dark">
|
||||
View
|
||||
</Link>
|
||||
<Link href={`/admin/events/${event._id}/edit`} className="text-primary hover:text-primary-dark">
|
||||
Edit
|
||||
</Link>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (window.confirm(`Are you sure you want to delete "${event.title}"?`)) {
|
||||
const success = await deleteEvent(event._id);
|
||||
if (success) {
|
||||
// Event was deleted successfully and removed from the events array
|
||||
} else {
|
||||
alert('Failed to delete event. Please try again.');
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredEvents.length === 0 && (
|
||||
{!loading && !error && events.length === 0 && (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">No events found matching the current filters.</p>
|
||||
</div>
|
||||
|
|
|
|||
256
frontend/src/hooks/useEvents.ts
Normal file
256
frontend/src/hooks/useEvents.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
// Define Event interface
|
||||
export interface Event {
|
||||
_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
date: string;
|
||||
time: string;
|
||||
location: string;
|
||||
category: 'social' | 'athletic' | 'board' | 'general' | 'educational';
|
||||
status: 'published' | 'draft' | 'archived';
|
||||
registrationRequired: boolean;
|
||||
registeredCount: number;
|
||||
maxAttendees?: number;
|
||||
recurring?: boolean;
|
||||
recurrencePattern?: string;
|
||||
tags?: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// Define filter interface
|
||||
export interface EventFilter {
|
||||
status?: string;
|
||||
category?: string;
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// Define pagination interface
|
||||
export interface Pagination {
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
// Define hook return interface
|
||||
export interface UseEventsReturn {
|
||||
events: Event[];
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
pagination: Pagination | null;
|
||||
fetchEvents: (filters?: EventFilter) => Promise<void>;
|
||||
getEventById: (id: string) => Promise<Event | null>;
|
||||
createEvent: (eventData: Omit<Event, '_id' | 'createdAt' | 'updatedAt'>) => Promise<Event | null>;
|
||||
updateEvent: (id: string, eventData: Partial<Event>) => Promise<Event | null>;
|
||||
deleteEvent: (id: string) => Promise<boolean>;
|
||||
registerForEvent: (id: string) => Promise<Event | null>;
|
||||
cancelRegistration: (id: string) => Promise<Event | null>;
|
||||
}
|
||||
|
||||
// API base URL
|
||||
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000/api';
|
||||
|
||||
/**
|
||||
* Hook for managing events data
|
||||
*/
|
||||
export function useEvents(): UseEventsReturn {
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [pagination, setPagination] = useState<Pagination | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch events with optional filtering
|
||||
*/
|
||||
const fetchEvents = useCallback(async (filters?: EventFilter) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Build query string from filters
|
||||
const queryParams = new URLSearchParams();
|
||||
if (filters) {
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
queryParams.append(key, String(value));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const response = await axios.get(`${API_URL}/events?${queryParams.toString()}`);
|
||||
setEvents(response.data.events);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (err) {
|
||||
console.error('Error fetching events:', err);
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch events'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get a single event by ID
|
||||
*/
|
||||
const getEventById = useCallback(async (id: string): Promise<Event | null> => {
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/events/${id}`);
|
||||
return response.data.event;
|
||||
} catch (err) {
|
||||
console.error(`Error fetching event with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new event
|
||||
*/
|
||||
const createEvent = useCallback(async (eventData: Omit<Event, '_id' | 'createdAt' | 'updatedAt'>): Promise<Event | null> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/events`, eventData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Refresh events list after creating
|
||||
fetchEvents();
|
||||
|
||||
return response.data.event;
|
||||
} catch (err) {
|
||||
console.error('Error creating event:', err);
|
||||
return null;
|
||||
}
|
||||
}, [fetchEvents]);
|
||||
|
||||
/**
|
||||
* Update an existing event
|
||||
*/
|
||||
const updateEvent = useCallback(async (id: string, eventData: Partial<Event>): Promise<Event | null> => {
|
||||
try {
|
||||
const response = await axios.put(`${API_URL}/events/${id}`, eventData, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setEvents(prevEvents =>
|
||||
prevEvents.map(event =>
|
||||
event._id === id ? { ...event, ...response.data.event } : event
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.event;
|
||||
} catch (err) {
|
||||
console.error(`Error updating event with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete an event
|
||||
*/
|
||||
const deleteEvent = useCallback(async (id: string): Promise<boolean> => {
|
||||
try {
|
||||
await axios.delete(`${API_URL}/events/${id}`, {
|
||||
headers: {
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setEvents(prevEvents => prevEvents.filter(event => event._id !== id));
|
||||
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error deleting event with ID ${id}:`, err);
|
||||
return false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Register for an event
|
||||
*/
|
||||
const registerForEvent = useCallback(async (id: string): Promise<Event | null> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/events/${id}/register`, {}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setEvents(prevEvents =>
|
||||
prevEvents.map(event =>
|
||||
event._id === id ? { ...event, ...response.data.event } : event
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.event;
|
||||
} catch (err) {
|
||||
console.error(`Error registering for event with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Cancel registration for an event
|
||||
*/
|
||||
const cancelRegistration = useCallback(async (id: string): Promise<Event | null> => {
|
||||
try {
|
||||
const response = await axios.post(`${API_URL}/events/${id}/cancel-registration`, {}, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
// Add authorization header if needed
|
||||
// 'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Update local state
|
||||
setEvents(prevEvents =>
|
||||
prevEvents.map(event =>
|
||||
event._id === id ? { ...event, ...response.data.event } : event
|
||||
)
|
||||
);
|
||||
|
||||
return response.data.event;
|
||||
} catch (err) {
|
||||
console.error(`Error cancelling registration for event with ID ${id}:`, err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load events on initial render
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, [fetchEvents]);
|
||||
|
||||
return {
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
pagination,
|
||||
fetchEvents,
|
||||
getEventById,
|
||||
createEvent,
|
||||
updateEvent,
|
||||
deleteEvent,
|
||||
registerForEvent,
|
||||
cancelRegistration
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue