From 8e986796e075a7e8faca7e065e7b06a349689924 Mon Sep 17 00:00:00 2001 From: TheMaddax Date: Wed, 26 Mar 2025 10:13:53 -0500 Subject: [PATCH] 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 --- backend/__tests__/events.test.ts | 232 +++++++ backend/__tests__/setup.ts | 25 + backend/jest.config.js | 25 + backend/package.json | 9 +- backend/src/controllers/eventController.ts | 410 ++++++++++++ backend/src/index.ts | 10 +- backend/src/models/Event.ts | 97 +++ backend/src/routes/events.ts | 20 + cline_docs/activeContext.md | 24 +- cline_docs/progress.md | 17 +- cline_docs/systemPatterns.md | 41 ++ .../src/app/admin/events/[id]/edit/page.tsx | 602 ++++++++++++++++++ frontend/src/app/admin/events/[id]/page.tsx | 265 ++++++++ frontend/src/app/admin/events/create/page.tsx | 510 +++++++++++++++ frontend/src/app/admin/events/page.tsx | 253 ++++---- frontend/src/hooks/useEvents.ts | 256 ++++++++ 16 files changed, 2648 insertions(+), 148 deletions(-) create mode 100644 backend/__tests__/events.test.ts create mode 100644 backend/__tests__/setup.ts create mode 100644 backend/jest.config.js create mode 100644 backend/src/controllers/eventController.ts create mode 100644 backend/src/models/Event.ts create mode 100644 backend/src/routes/events.ts create mode 100644 frontend/src/app/admin/events/[id]/edit/page.tsx create mode 100644 frontend/src/app/admin/events/[id]/page.tsx create mode 100644 frontend/src/app/admin/events/create/page.tsx create mode 100644 frontend/src/hooks/useEvents.ts diff --git a/backend/__tests__/events.test.ts b/backend/__tests__/events.test.ts new file mode 100644 index 0000000..aa324f4 --- /dev/null +++ b/backend/__tests__/events.test.ts @@ -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'); + }); + }); +}); diff --git a/backend/__tests__/setup.ts b/backend/__tests__/setup.ts new file mode 100644 index 0000000..03afbaf --- /dev/null +++ b/backend/__tests__/setup.ts @@ -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(); + } +}); diff --git a/backend/jest.config.js b/backend/jest.config.js new file mode 100644 index 0000000..4f2ac8a --- /dev/null +++ b/backend/jest.config.js @@ -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'], +}; diff --git a/backend/package.json b/backend/package.json index b3e18e4..6c8fd0a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } diff --git a/backend/src/controllers/eventController.ts b/backend/src/controllers/eventController.ts new file mode 100644 index 0000000..5a356f0 --- /dev/null +++ b/backend/src/controllers/eventController.ts @@ -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' + } + }); + } +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 671377a..e9bc0dc 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) => { diff --git a/backend/src/models/Event.ts b/backend/src/models/Event.ts new file mode 100644 index 0000000..9a1140b --- /dev/null +++ b/backend/src/models/Event.ts @@ -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('Event', EventSchema); diff --git a/backend/src/routes/events.ts b/backend/src/routes/events.ts new file mode 100644 index 0000000..36e6a71 --- /dev/null +++ b/backend/src/routes/events.ts @@ -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; diff --git a/cline_docs/activeContext.md b/cline_docs/activeContext.md index be2101c..bb15476 100644 --- a/cline_docs/activeContext.md +++ b/cline_docs/activeContext.md @@ -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: diff --git a/cline_docs/progress.md b/cline_docs/progress.md index 64090b2..fd3558d 100644 --- a/cline_docs/progress.md +++ b/cline_docs/progress.md @@ -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 diff --git a/cline_docs/systemPatterns.md b/cline_docs/systemPatterns.md index 9bc7427..33659d7 100644 --- a/cline_docs/systemPatterns.md +++ b/cline_docs/systemPatterns.md @@ -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** diff --git a/frontend/src/app/admin/events/[id]/edit/page.tsx b/frontend/src/app/admin/events/[id]/edit/page.tsx new file mode 100644 index 0000000..8848758 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/edit/page.tsx @@ -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({ + title: '', + description: '', + date: '', + time: '', + location: '', + category: 'social', + status: 'draft', + registrationRequired: false, + maxAttendees: undefined, + recurring: false, + recurrencePattern: '', + tags: '', + registeredCount: 0, + }); + + const [errors, setErrors] = useState({}); + 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 + ) => { + 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 ( +
+
+
+ Loading event data... +
+
+ ); + } + + if (loadError) { + return ( +
+
+

Error

+

{loadError}

+
+ + Return to Events + +
+
+
+ ); + } + + return ( +
+
+

Edit Event

+ + Cancel + +
+ +
+ {/* Error message */} + {submitError && ( +
+

Error

+

{submitError}

+
+ )} + +
+ {/* Title field */} +
+ + + {errors.title && ( +

+ {errors.title} +

+ )} +
+ + {/* Description field */} +
+ + + {errors.description && ( +

+ {errors.description} +

+ )} +
+ + {/* Date and Time fields */} +
+
+ + + {errors.date && ( +

+ {errors.date} +

+ )} +
+ +
+ + + {errors.time && ( +

+ {errors.time} +

+ )} +
+
+ + {/* Location field */} +
+ + + {errors.location && ( +

+ {errors.location} +

+ )} +
+ + {/* Category and Status fields */} +
+
+ + + {errors.category && ( +

+ {errors.category} +

+ )} +
+ +
+ + + {errors.status && ( +

+ {errors.status} +

+ )} +
+
+ + {/* Registration fields */} +
+
+ + +
+ + {formData.registrationRequired && ( +
+ + + {errors.maxAttendees && ( +

+ {errors.maxAttendees} +

+ )} +
+ )} + + {formData.registrationRequired && ( +
+ + +

+ Number of people currently registered for this event. +

+
+ )} +
+ + {/* Recurring event fields */} +
+
+ + +
+ + {formData.recurring && ( +
+ + + {errors.recurrencePattern && ( +

+ {errors.recurrencePattern} +

+ )} +
+ )} +
+ + {/* Tags field */} +
+ + +
+ + {/* Submit button */} +
+ + Cancel + + +
+ +

+ * Required fields +

+
+
+
+ ); +} diff --git a/frontend/src/app/admin/events/[id]/page.tsx b/frontend/src/app/admin/events/[id]/page.tsx new file mode 100644 index 0000000..57e6ec6 --- /dev/null +++ b/frontend/src/app/admin/events/[id]/page.tsx @@ -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(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 ( +
+
+
+ Loading event data... +
+
+ ); + } + + if (error) { + return ( +
+
+

Error

+

{error}

+
+ + Return to Events + +
+
+
+ ); + } + + 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 ( +
+
+

{event.title}

+
+ + Back to Events + + + Edit Event + +
+
+ +
+
+
+ {/* Event details */} +
+

Event Details

+ +
+

{event.description}

+
+
+ + {/* Tags */} + {event.tags && event.tags.length > 0 && ( +
+

Tags

+
+ {event.tags.map((tag: string, index: number) => ( + + {tag} + + ))} +
+
+ )} + + {/* Recurring info */} + {event.recurring && ( +
+

Recurrence

+

{event.recurrencePattern}

+
+ )} +
+ +
+ {/* Event metadata */} +
+

Event Information

+ +
+
+

Date

+

{formatDate(event.date)}

+
+ +
+

Time

+

{event.time}

+
+ +
+

Location

+

{event.location}

+
+ +
+

Category

+

{event.category}

+
+ +
+

Status

+ + {event.status.charAt(0).toUpperCase() + event.status.slice(1)} + +
+
+
+ + {/* Registration info */} +
+

Registration

+ + {event.registrationRequired ? ( +
+

Registration is required for this event.

+ +
+

Current Registrations

+

{event.registeredCount}

+
+ + {event.maxAttendees && ( +
+

Maximum Attendees

+

{event.maxAttendees}

+
+ )} + + {event.maxAttendees && ( +
+
+
+
+

+ {event.registeredCount} of {event.maxAttendees} spots filled + ({Math.round((event.registeredCount / event.maxAttendees) * 100)}%) +

+
+ )} +
+ ) : ( +

Registration is not required for this event.

+ )} +
+ + {/* Danger zone */} +
+

Danger Zone

+

+ Deleting this event will permanently remove it and cannot be undone. +

+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/app/admin/events/create/page.tsx b/frontend/src/app/admin/events/create/page.tsx new file mode 100644 index 0000000..0effbe9 --- /dev/null +++ b/frontend/src/app/admin/events/create/page.tsx @@ -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({ + title: '', + description: '', + date: '', + time: '', + location: '', + category: 'social', + status: 'draft', + registrationRequired: false, + maxAttendees: undefined, + recurring: false, + recurrencePattern: '', + tags: '', + }); + + const [errors, setErrors] = useState({}); + 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 + ) => { + 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 ( +
+
+

Create New Event

+ + Cancel + +
+ +
+ {/* Error message */} + {submitError && ( +
+

Error

+

{submitError}

+
+ )} + +
+ {/* Title field */} +
+ + + {errors.title && ( +

+ {errors.title} +

+ )} +
+ + {/* Description field */} +
+ + + {errors.description && ( +

+ {errors.description} +

+ )} +
+ + {/* Date and Time fields */} +
+
+ + + {errors.date && ( +

+ {errors.date} +

+ )} +
+ +
+ + + {errors.time && ( +

+ {errors.time} +

+ )} +
+
+ + {/* Location field */} +
+ + + {errors.location && ( +

+ {errors.location} +

+ )} +
+ + {/* Category and Status fields */} +
+
+ + + {errors.category && ( +

+ {errors.category} +

+ )} +
+ +
+ + + {errors.status && ( +

+ {errors.status} +

+ )} +
+
+ + {/* Registration fields */} +
+
+ + +
+ + {formData.registrationRequired && ( +
+ + + {errors.maxAttendees && ( +

+ {errors.maxAttendees} +

+ )} +
+ )} +
+ + {/* Recurring event fields */} +
+
+ + +
+ + {formData.recurring && ( +
+ + + {errors.recurrencePattern && ( +

+ {errors.recurrencePattern} +

+ )} +
+ )} +
+ + {/* Tags field */} +
+ + +
+ + {/* Submit button */} +
+ + Cancel + + +
+ +

+ * Required fields +

+
+
+
+ ); +} diff --git a/frontend/src/app/admin/events/page.tsx b/frontend/src/app/admin/events/page.tsx index 1c889fa..be84fc5 100644 --- a/frontend/src/app/admin/events/page.tsx +++ b/frontend/src/app/admin/events/page.tsx @@ -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 = ({ + @@ -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() { - {filteredEvents.map((event) => ( - - -
-
-
{event.title}
-
- - {event.category.charAt(0).toUpperCase() + event.category.slice(1)} - -
-
-
- - -
{event.date}
-
{event.time}
- - -
{event.location}
- - - - {event.status.charAt(0).toUpperCase() + event.status.slice(1)} - - - - {event.registrationRequired ? ( - {event.registeredCount} registered - ) : ( - Not required - )} - - -
- - View - - - Edit - - + {loading ? ( + + +
+
+

Loading events...

- ))} + ) : error ? ( + + +

Error loading events. Please try again.

+ + + ) : events.length === 0 ? ( + + +

No events found matching the current filters.

+ + + ) : ( + events.map((event) => ( + + +
+
+
+ {event.title} +
+
+ + {event.category.charAt(0).toUpperCase() + event.category.slice(1)} + +
+
+
+ + +
{formatDate(event.date)}
+
{event.time}
+ + +
{event.location}
+ + + + {event.status.charAt(0).toUpperCase() + event.status.slice(1)} + + + + {event.registrationRequired ? ( + {event.registeredCount} registered + ) : ( + Not required + )} + + +
+ + View + + + Edit + + +
+ + + )) + )}
- {filteredEvents.length === 0 && ( + {!loading && !error && events.length === 0 && (

No events found matching the current filters.

diff --git a/frontend/src/hooks/useEvents.ts b/frontend/src/hooks/useEvents.ts new file mode 100644 index 0000000..09aeb2f --- /dev/null +++ b/frontend/src/hooks/useEvents.ts @@ -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; + getEventById: (id: string) => Promise; + createEvent: (eventData: Omit) => Promise; + updateEvent: (id: string, eventData: Partial) => Promise; + deleteEvent: (id: string) => Promise; + registerForEvent: (id: string) => Promise; + cancelRegistration: (id: string) => Promise; +} + +// 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([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [pagination, setPagination] = useState(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 => { + 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): Promise => { + 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): Promise => { + 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 => { + 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 => { + 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 => { + 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 + }; +}