diff --git a/ATTENDEE_TRACKING_FEATURE.md b/ATTENDEE_TRACKING_FEATURE.md new file mode 100644 index 0000000..a4e76be --- /dev/null +++ b/ATTENDEE_TRACKING_FEATURE.md @@ -0,0 +1,214 @@ +# Event Attendee Tracking & Contributions Feature + +## Overview + +Added comprehensive attendee tracking and individual contribution management for cleanup events. Organizers can now mark who attended, track individual litter collection amounts, and add notes about each volunteer's contribution. + +## Database Changes + +### Schema Updates +- Added `litter_collected` (DECIMAL 10,2) to `event_registrations` table +- Added `contribution_note` (TEXT) to `event_registrations` table +- Migration: `20251118183315_add_attendee_contributions` + +### New Type Definitions +```typescript +interface EventAttendee { + id: string; + userId: string; + eventId: string; + registeredAt: Date; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + user: { + id: string; + username: string; + firstName?: string; + lastName?: string; + avatarUrl?: string; + }; +} +``` + +## Backend API Endpoints + +### New Endpoints +1. **GET** `/api/events/:id/attendees` - Get all attendees for an event +2. **PATCH** `/api/events/:id/attendees/:attendeeId` - Update single attendee status +3. **POST** `/api/events/:id/attendees/bulk` - Bulk update attendees + +### Updated Endpoints +- **POST** `/api/events/:id/complete` - Now accepts optional `attendees` array + +## Frontend Components + +### 1. AttendeeManagement Component +**Location:** `src/components/event/AttendeeManagement.tsx` + +**Features:** +- View all registered attendees +- Mark who attended with checkboxes +- Track individual litter collection (kg) +- Add contribution notes for each volunteer +- Real-time summary stats (registered, attended, total litter) +- Bulk save changes + +**Usage:** +```tsx + +``` + +### 2. CompletedEventDetails Component +**Location:** `src/components/event/CompletedEventDetails.tsx` + +**Features:** +- Display event completion summary +- Show top contributors with rankings (šŸ„‡šŸ„ˆšŸ„‰) +- List all attendees with avatars +- Display event photos in grid +- Expandable contributor list + +**Usage:** +```tsx + +``` + +### 3. Updated EventCompletionForm +**Location:** `src/components/event/EventCompletionForm.tsx` + +**New Flow:** +1. **Step 1: Manage Attendees** - Mark attendance and individual contributions +2. **Step 2: Event Summary** - Add photos and confirm total litter collected +3. Auto-calculates total litter from individual contributions + +## Event Completion Workflow + +### For Organizers: + +1. **After Event Date Passes:** + - "Complete Event" button appears in event detail modal + +2. **Step 1 - Manage Attendees:** + - View all registered participants + - Check boxes for who actually attended + - Enter litter collected by each volunteer (kg) + - Add optional notes about contributions + - See real-time totals + +3. **Step 2 - Event Summary:** + - Total litter auto-calculated from attendees + - Upload event photos (max 5) + - Review and submit + +4. **After Completion:** + - Event status changes to "completed" + - Attendee data saved + - Event marked with completion badge + +### For Viewing Completed Events: + +**Displays:** +- Total litter collected +- Number of volunteers +- Top contributors with rankings +- All attendees list +- Event photos +- Individual contribution notes + +## Service Layer Updates + +### Backend (`server/src/services/eventService.ts`) +- `getEventAttendees(eventId)` - Fetch attendees with user details +- `updateAttendeeStatus(eventId, userId, data)` - Update single attendee +- `bulkUpdateAttendees(eventId, attendees)` - Bulk update multiple attendees + +### Frontend (`src/services/eventService.ts`) +- Updated `completeEvent()` to handle attendee data +- Automatically calls bulk update before completing event + +## UI/UX Features + +### Attendee Management Table +- Sortable by name +- Avatar display +- Inline editing +- Real-time validation +- Change tracking (shows count of unsaved changes) + +### Completed Event Display +- Green success badge +- Medal icons for top 3 contributors +- Expandable lists +- Photo gallery with click-to-enlarge +- Responsive grid layouts + +### Summary Statistics +- Registered count +- Attended count +- Total litter collected +- Individual contributions + +## Data Flow + +``` +1. Organizer clicks "Complete Event" + ↓ +2. AttendeeManagement loads registered users + ↓ +3. Organizer marks attendance & contributions + ↓ +4. Data saved via bulk update API + ↓ +5. EventCompletionForm shows summary + ↓ +6. Organizer adds photos & confirms + ↓ +7. Event marked complete + ↓ +8. CompletedEventDetails displays results +``` + +## Validation + +- Litter amounts must be positive numbers +- Attended status is boolean +- Contribution notes are optional text +- Total litter calculated from individual contributions + +## Future Enhancements + +Potential additions: +- Export attendee data to CSV +- Send completion certificates to volunteers +- Impact score updates based on contributions +- Leaderboards for top volunteers +- Photo uploads per attendee +- Time tracking for volunteer hours + +## Testing + +To test the feature: +1. Create an event with a past date +2. Register multiple users +3. As organizer, click "Complete Event" +4. Mark attendees and add contributions +5. Upload photos and complete +6. View completed event details + +## Migration Instructions + +If deploying to a new environment: +1. Run `npx prisma migrate deploy` in server directory +2. Or manually run: `npx tsx src/scripts/applyMigration.ts` +3. Verify with: `npx tsx src/scripts/verifyMigration.ts` +4. Restart the server diff --git a/START_SERVERS.md b/START_SERVERS.md new file mode 100644 index 0000000..bf68365 --- /dev/null +++ b/START_SERVERS.md @@ -0,0 +1,54 @@ +# Starting the Application + +## Backend Server + +Open a terminal and run: + +```bash +cd server +npm run dev +``` + +The backend server should start on `http://localhost:3005` + +You should see output like: +``` +Server running on port 3005 +Connected to database +``` + +## Frontend Development Server + +Open another terminal and run: + +```bash +npm run dev +``` + +The frontend should start on `http://localhost:5173` + +## Troubleshooting + +### Backend won't start +- Check if port 3005 is already in use: `lsof -ti:3005` +- Check database connection in `server/.env` +- Run `cd server && npm install` to ensure dependencies are installed + +### Frontend won't start +- Check if port 5173 is already in use +- Run `npm install` to ensure dependencies are installed +- Check `.env.local` for correct API URL + +### "Server returned non-JSON response" error +- This means the backend server is not running +- Start the backend server first (see above) +- Check that `VITE_API_BASE_URL` in `.env.local` matches your backend URL + +## Quick Check + +Test if backend is running: +```bash +curl http://localhost:3005/api/events +``` + +Should return JSON with events list. diff --git a/server/prisma/migrations/20251118183315_add_attendee_contributions/migration.sql b/server/prisma/migrations/20251118183315_add_attendee_contributions/migration.sql new file mode 100644 index 0000000..15b19d1 --- /dev/null +++ b/server/prisma/migrations/20251118183315_add_attendee_contributions/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE `event_registrations` +ADD COLUMN `litter_collected` DECIMAL(10, 2) NULL, +ADD COLUMN `contribution_note` TEXT NULL; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index e683a87..3fb21f6 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -96,13 +96,15 @@ model Event { } model EventRegistration { - id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36) - userId String @map("user_id") @db.VarChar(36) - eventId String @map("event_id") @db.VarChar(36) - registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0) - attended Boolean? @default(false) - user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1") - event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_2") + id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36) + userId String @map("user_id") @db.VarChar(36) + eventId String @map("event_id") @db.VarChar(36) + registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0) + attended Boolean? @default(false) + litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2) + contributionNote String? @map("contribution_note") @db.Text + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1") + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_2") @@unique([userId, eventId], map: "unique_user_event") @@index([eventId], map: "event_id") diff --git a/server/src/controllers/eventController.ts b/server/src/controllers/eventController.ts index cdbfc5d..7a9a911 100644 --- a/server/src/controllers/eventController.ts +++ b/server/src/controllers/eventController.ts @@ -405,4 +405,133 @@ export const eventController = { }); } }, + + // Get event attendees + async getEventAttendees(req: AuthRequest, res: Response) { + try { + const { id } = req.params; + const attendees = await eventService.getEventAttendees(id); + res.json(attendees); + } catch (error: any) { + console.error('Error fetching event attendees:', error); + res.status(500).json({ + code: 'FETCH_ATTENDEES_ERROR', + message: 'Failed to fetch event attendees', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Update attendee status + async updateAttendeeStatus(req: AuthRequest, res: Response) { + try { + const clerkId = req.userId; + if (!clerkId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const userId = await findOrCreateUser(clerkId); + const { id, attendeeId } = req.params; + const event = await eventService.getEventById(id); + + if (!event) { + return res.status(404).json({ + code: 'EVENT_NOT_FOUND', + message: 'Event not found', + timestamp: new Date().toISOString(), + }); + } + + // Check if user is the organizer + if (event.organizerId !== userId) { + return res.status(403).json({ + code: 'FORBIDDEN', + message: 'Only the event organizer can update attendee status', + timestamp: new Date().toISOString(), + }); + } + + const { attended, litterCollected, contributionNote } = req.body; + + const updatedAttendee = await eventService.updateAttendeeStatus(id, attendeeId, { + attended, + litterCollected: litterCollected !== undefined ? parseFloat(litterCollected) : undefined, + contributionNote, + }); + + res.json(updatedAttendee); + } catch (error: any) { + console.error('Error updating attendee status:', error); + res.status(500).json({ + code: 'UPDATE_ATTENDEE_ERROR', + message: 'Failed to update attendee status', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, + + // Bulk update attendees + async bulkUpdateAttendees(req: AuthRequest, res: Response) { + try { + const clerkId = req.userId; + if (!clerkId) { + return res.status(401).json({ + code: 'UNAUTHORIZED', + message: 'User not authenticated', + timestamp: new Date().toISOString(), + }); + } + + const userId = await findOrCreateUser(clerkId); + const { id } = req.params; + const event = await eventService.getEventById(id); + + if (!event) { + return res.status(404).json({ + code: 'EVENT_NOT_FOUND', + message: 'Event not found', + timestamp: new Date().toISOString(), + }); + } + + // Check if user is the organizer + if (event.organizerId !== userId) { + return res.status(403).json({ + code: 'FORBIDDEN', + message: 'Only the event organizer can update attendees', + timestamp: new Date().toISOString(), + }); + } + + const { attendees } = req.body; + + if (!Array.isArray(attendees)) { + return res.status(400).json({ + code: 'VALIDATION_ERROR', + message: 'attendees must be an array', + timestamp: new Date().toISOString(), + }); + } + + await eventService.bulkUpdateAttendees(id, attendees); + + // Fetch updated attendees + const updatedAttendees = await eventService.getEventAttendees(id); + res.json(updatedAttendees); + } catch (error: any) { + console.error('Error bulk updating attendees:', error); + res.status(500).json({ + code: 'BULK_UPDATE_ATTENDEES_ERROR', + message: 'Failed to bulk update attendees', + details: error.message, + timestamp: new Date().toISOString(), + }); + } + }, }; diff --git a/server/src/routes/eventRoutes.ts b/server/src/routes/eventRoutes.ts index 3b85b69..698e38f 100644 --- a/server/src/routes/eventRoutes.ts +++ b/server/src/routes/eventRoutes.ts @@ -8,6 +8,7 @@ const router = Router(); router.get('/', eventController.getEvents); router.get('/:id', eventController.getEvent); router.get('/:id/registrations', eventController.getEventRegistrations); +router.get('/:id/attendees', eventController.getEventAttendees); // Protected routes (require authentication) router.post('/', requireAuth, eventController.createEvent); @@ -17,5 +18,7 @@ router.post('/:id/register', requireAuth, eventController.registerForEvent); router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent); router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus); router.post('/:id/complete', requireAuth, eventController.completeEvent); +router.patch('/:id/attendees/:attendeeId', requireAuth, eventController.updateAttendeeStatus); +router.post('/:id/attendees/bulk', requireAuth, eventController.bulkUpdateAttendees); export default router; diff --git a/server/src/scripts/applyMigration.ts b/server/src/scripts/applyMigration.ts new file mode 100644 index 0000000..ae72f7a --- /dev/null +++ b/server/src/scripts/applyMigration.ts @@ -0,0 +1,57 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function applyMigration() { + try { + console.log('Applying migration: add_attendee_contributions'); + + // Check if columns exist + const result = await prisma.$queryRaw` + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'beach_litter_mapping' + AND TABLE_NAME = 'event_registrations' + AND COLUMN_NAME IN ('litter_collected', 'contribution_note') + ` as any[]; + + console.log('Existing columns:', result); + + if (result.length === 0) { + console.log('Adding columns...'); + await prisma.$executeRaw` + ALTER TABLE event_registrations + ADD COLUMN litter_collected DECIMAL(10, 2) NULL, + ADD COLUMN contribution_note TEXT NULL + `; + console.log('āœ“ Columns added successfully'); + } else if (result.length === 1) { + const existingColumn = result[0].COLUMN_NAME; + const missingColumn = existingColumn === 'litter_collected' ? 'contribution_note' : 'litter_collected'; + console.log(`Adding missing column: ${missingColumn}`); + await prisma.$executeRaw` + ALTER TABLE event_registrations + ADD COLUMN ${missingColumn} ${missingColumn === 'litter_collected' ? 'DECIMAL(10, 2)' : 'TEXT'} NULL + `; + console.log('āœ“ Column added successfully'); + } else { + console.log('āœ“ Columns already exist'); + } + + } catch (error) { + console.error('Error applying migration:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +applyMigration() + .then(() => { + console.log('Migration completed'); + process.exit(0); + }) + .catch((error) => { + console.error('Migration failed:', error); + process.exit(1); + }); diff --git a/server/src/scripts/testAttendees.ts b/server/src/scripts/testAttendees.ts new file mode 100644 index 0000000..b754894 --- /dev/null +++ b/server/src/scripts/testAttendees.ts @@ -0,0 +1,89 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function testAttendees() { + try { + console.log('Testing attendees query...\n'); + + // Get all events + const events = await prisma.event.findMany({ + take: 1, + }); + + if (events.length === 0) { + console.log('No events found in database'); + return; + } + + const eventId = events[0].id; + console.log(`Testing with event ID: ${eventId}`); + console.log(`Event title: ${events[0].title}\n`); + + // Get registrations + const registrations = await prisma.eventRegistration.findMany({ + where: { eventId }, + include: { + user: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + }, + orderBy: { + registeredAt: 'asc', + }, + }); + + console.log(`Found ${registrations.length} registrations\n`); + + if (registrations.length > 0) { + console.log('Sample registration:'); + const reg = registrations[0]; + console.log({ + id: reg.id, + userId: reg.userId, + eventId: reg.eventId, + registeredAt: reg.registeredAt, + attended: reg.attended, + litterCollected: reg.litterCollected, + contributionNote: reg.contributionNote, + user: reg.user, + }); + } + + // Test the mapping + const mapped = registrations.map((reg) => ({ + id: reg.id, + userId: reg.userId, + eventId: reg.eventId, + registeredAt: reg.registeredAt || new Date(), + attended: reg.attended || false, + litterCollected: reg.litterCollected ? parseFloat(reg.litterCollected.toString()) : undefined, + contributionNote: reg.contributionNote || undefined, + user: reg.user, + })); + + console.log('\nMapped data:'); + console.log(JSON.stringify(mapped, null, 2)); + + console.log('\nāœ“ Test completed successfully'); + + } catch (error) { + console.error('Error testing attendees:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +testAttendees() + .then(() => process.exit(0)) + .catch((error) => { + console.error('Test failed:', error); + process.exit(1); + }); diff --git a/server/src/scripts/verifyMigration.ts b/server/src/scripts/verifyMigration.ts new file mode 100644 index 0000000..801db01 --- /dev/null +++ b/server/src/scripts/verifyMigration.ts @@ -0,0 +1,43 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +async function verifyMigration() { + try { + console.log('Verifying migration...'); + + const result = await prisma.$queryRaw` + SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'beach_litter_mapping' + AND TABLE_NAME = 'event_registrations' + ORDER BY ORDINAL_POSITION + ` as any[]; + + console.log('\nColumns in event_registrations table:'); + console.table(result); + + const hasLitterCollected = result.some((col: any) => col.COLUMN_NAME === 'litter_collected'); + const hasContributionNote = result.some((col: any) => col.COLUMN_NAME === 'contribution_note'); + + if (hasLitterCollected && hasContributionNote) { + console.log('\nāœ“ Migration verified successfully!'); + console.log('āœ“ litter_collected column exists'); + console.log('āœ“ contribution_note column exists'); + } else { + console.log('\nāœ— Migration incomplete:'); + if (!hasLitterCollected) console.log('āœ— litter_collected column missing'); + if (!hasContributionNote) console.log('āœ— contribution_note column missing'); + } + + } catch (error) { + console.error('Error verifying migration:', error); + throw error; + } finally { + await prisma.$disconnect(); + } +} + +verifyMigration() + .then(() => process.exit(0)) + .catch(() => process.exit(1)); diff --git a/server/src/services/eventService.ts b/server/src/services/eventService.ts index ed7e42c..994d5a1 100644 --- a/server/src/services/eventService.ts +++ b/server/src/services/eventService.ts @@ -372,4 +372,111 @@ export const eventService = { updatedAt: event.updatedAt, }; }, + + async getEventAttendees(eventId: string) { + const registrations = await prisma.eventRegistration.findMany({ + where: { eventId }, + include: { + user: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + }, + orderBy: { + registeredAt: 'asc', + }, + }); + + return registrations.map((reg) => ({ + id: reg.id, + userId: reg.userId, + eventId: reg.eventId, + registeredAt: reg.registeredAt || new Date(), + attended: reg.attended || false, + litterCollected: reg.litterCollected ? parseFloat(reg.litterCollected.toString()) : undefined, + contributionNote: reg.contributionNote || undefined, + user: reg.user, + })); + }, + + async updateAttendeeStatus( + eventId: string, + userId: string, + data: { + attended?: boolean; + litterCollected?: number; + contributionNote?: string; + } + ) { + const registration = await prisma.eventRegistration.update({ + where: { + userId_eventId: { + userId, + eventId, + }, + }, + data: { + attended: data.attended, + litterCollected: data.litterCollected, + contributionNote: data.contributionNote, + }, + include: { + user: { + select: { + id: true, + username: true, + firstName: true, + lastName: true, + avatarUrl: true, + }, + }, + }, + }); + + return { + id: registration.id, + userId: registration.userId, + eventId: registration.eventId, + registeredAt: registration.registeredAt || new Date(), + attended: registration.attended || false, + litterCollected: registration.litterCollected ? parseFloat(registration.litterCollected.toString()) : undefined, + contributionNote: registration.contributionNote || undefined, + user: registration.user, + }; + }, + + async bulkUpdateAttendees( + eventId: string, + attendees: Array<{ + userId: string; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + }> + ) { + const updates = await Promise.all( + attendees.map((attendee) => + prisma.eventRegistration.update({ + where: { + userId_eventId: { + userId: attendee.userId, + eventId, + }, + }, + data: { + attended: attendee.attended, + litterCollected: attendee.litterCollected, + contributionNote: attendee.contributionNote, + }, + }) + ) + ); + + return updates; + }, }; diff --git a/server/src/services/userService.ts b/server/src/services/userService.ts index 5b89425..d24fa27 100644 --- a/server/src/services/userService.ts +++ b/server/src/services/userService.ts @@ -97,6 +97,17 @@ export class UserService { organizedEvents: true, }, }, + eventRegistrations: { + where: { + attended: true, + litterCollected: { + not: null, + }, + }, + select: { + litterCollected: true, + }, + }, }, }); @@ -116,11 +127,17 @@ export class UserService { } } + // Calculate total litter collected across all attended events + const totalLitterCollected = user.eventRegistrations.reduce((sum, reg) => { + return sum + (reg.litterCollected ? Number(reg.litterCollected) : 0); + }, 0); + return { ...user, areasOfInterest, reportCount: user._count.reports, eventCount: user._count.eventRegistrations, + totalLitterCollected, stats: { reportsSubmitted: user._count.reports, eventsAttended: user._count.eventRegistrations, diff --git a/src/components/event/AttendeeManagement.tsx b/src/components/event/AttendeeManagement.tsx new file mode 100644 index 0000000..b1bf730 --- /dev/null +++ b/src/components/event/AttendeeManagement.tsx @@ -0,0 +1,277 @@ +import { useState, useEffect } from 'react'; +import type { EventAttendee } from '@/types'; + +interface AttendeeManagementProps { + eventId: string; + onClose: () => void; + onSave: (attendees: Array<{ + userId: string; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + }>) => Promise; + isLoading?: boolean; +} + +export function AttendeeManagement({ + eventId, + onClose, + onSave, + isLoading = false, +}: AttendeeManagementProps) { + const [attendees, setAttendees] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [editedAttendees, setEditedAttendees] = useState>>(new Map()); + + useEffect(() => { + fetchAttendees(); + }, [eventId]); + + const fetchAttendees = async () => { + try { + setLoading(true); + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api'; + const response = await fetch(`${API_BASE_URL}/events/${eventId}/attendees`, { + headers: { + 'Authorization': `Bearer ${await (window as any).Clerk?.session?.getToken()}`, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + console.error('Failed to fetch attendees:', response.status, errorText); + throw new Error(`Failed to fetch attendees: ${response.status}`); + } + + const contentType = response.headers.get('content-type'); + if (!contentType || !contentType.includes('application/json')) { + const text = await response.text(); + console.error('Non-JSON response:', text); + throw new Error('Server returned non-JSON response'); + } + + const data = await response.json(); + setAttendees(data); + } catch (err) { + console.error('Error in fetchAttendees:', err); + setError(err instanceof Error ? err.message : 'Failed to load attendees'); + } finally { + setLoading(false); + } + }; + + const handleAttendeeChange = (userId: string, field: string, value: any) => { + setEditedAttendees(prev => { + const updated = new Map(prev); + const current = updated.get(userId) || {}; + updated.set(userId, { ...current, [field]: value }); + return updated; + }); + }; + + const getAttendeeValue = (attendee: EventAttendee, field: keyof EventAttendee) => { + const edited = editedAttendees.get(attendee.userId); + return edited && field in edited ? edited[field] : attendee[field]; + }; + + const handleSave = async () => { + const updates = Array.from(editedAttendees.entries()).map(([userId, changes]) => { + const attendee = attendees.find(a => a.userId === userId); + return { + userId, + attended: changes.attended ?? attendee?.attended ?? false, + litterCollected: changes.litterCollected ?? attendee?.litterCollected, + contributionNote: changes.contributionNote ?? attendee?.contributionNote, + }; + }); + + await onSave(updates); + }; + + const getTotalLitter = () => { + return attendees.reduce((sum, attendee) => { + const edited = editedAttendees.get(attendee.userId); + const litter = edited?.litterCollected ?? attendee.litterCollected ?? 0; + return sum + litter; + }, 0); + }; + + const getAttendedCount = () => { + return attendees.filter(attendee => { + const edited = editedAttendees.get(attendee.userId); + return edited?.attended ?? attendee.attended; + }).length; + }; + + if (loading) { + return ( +
+
+
+
+
+ ); + } + + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (attendees.length === 0) { + return ( +
+
+

No registered attendees for this event

+ +
+
+ ); + } + + return ( +
+ {/* Header */} +
+

Manage Attendees

+

+ Mark who attended and track individual contributions +

+
+ + {/* Summary Stats */} +
+
+
+

Registered

+

{attendees.length}

+
+
+

Attended

+

{getAttendedCount()}

+
+
+

Total Litter (kg)

+

{getTotalLitter().toFixed(1)}

+
+
+
+ + {/* Attendee List */} +
+ + + + + + + + + + + {attendees.map((attendee) => ( + + + + + + + ))} + +
+ Attendee + + Attended + + Litter (kg) + + Note +
+
+ {attendee.user.avatarUrl ? ( + {attendee.user.username} + ) : ( +
+ + {attendee.user.username.charAt(0).toUpperCase()} + +
+ )} +
+

+ {attendee.user.firstName && attendee.user.lastName + ? `${attendee.user.firstName} ${attendee.user.lastName}` + : attendee.user.username} +

+

@{attendee.user.username}

+
+
+
+ handleAttendeeChange(attendee.userId, 'attended', e.target.checked)} + className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded cursor-pointer" + /> + + handleAttendeeChange(attendee.userId, 'litterCollected', e.target.value ? parseFloat(e.target.value) : undefined)} + placeholder="0.0" + className="w-24 px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + + handleAttendeeChange(attendee.userId, 'contributionNote', e.target.value)} + placeholder="Optional note..." + className="w-full px-2 py-1 text-sm border border-gray-300 rounded focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ + {/* Footer Actions */} +
+ + +
+
+ ); +} diff --git a/src/components/event/CompletedEventDetails.tsx b/src/components/event/CompletedEventDetails.tsx new file mode 100644 index 0000000..62d783b --- /dev/null +++ b/src/components/event/CompletedEventDetails.tsx @@ -0,0 +1,209 @@ +import { useState, useEffect } from 'react'; +import type { EventAttendee } from '@/types'; + +interface CompletedEventDetailsProps { + eventId: string; + totalLitterCollected: number; + photos?: string[]; +} + +export function CompletedEventDetails({ + eventId, + totalLitterCollected, + photos = [], +}: CompletedEventDetailsProps) { + const [attendees, setAttendees] = useState([]); + const [loading, setLoading] = useState(true); + const [showAllAttendees, setShowAllAttendees] = useState(false); + + useEffect(() => { + fetchAttendees(); + }, [eventId]); + + const fetchAttendees = async () => { + try { + setLoading(true); + const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3005/api'; + const token = await (window as any).Clerk?.session?.getToken(); + const headers: HeadersInit = {}; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(`${API_BASE_URL}/events/${eventId}/attendees`, { headers }); + + if (response.ok) { + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + const data = await response.json(); + setAttendees(data.filter((a: EventAttendee) => a.attended)); + } else { + console.warn('Non-JSON response when fetching attendees'); + } + } else { + console.error('Failed to fetch attendees:', response.status, await response.text()); + } + } catch (error) { + console.error('Error fetching attendees:', error); + } finally { + setLoading(false); + } + }; + + const topContributors = attendees + .filter(a => a.litterCollected && a.litterCollected > 0) + .sort((a, b) => (b.litterCollected || 0) - (a.litterCollected || 0)) + .slice(0, showAllAttendees ? undefined : 5); + + const attendedCount = attendees.length; + + return ( +
+ {/* Summary Card */} +
+
+ + + +
+

Event Completed

+

+ {totalLitterCollected} kg of litter collected by {attendedCount} {attendedCount === 1 ? 'volunteer' : 'volunteers'} +

+
+
+
+ + {/* Top Contributors */} + {topContributors.length > 0 && ( +
+

Top Contributors

+
+ {topContributors.map((attendee, index) => ( +
+
+
+ {index < 3 && ( +
+ #{index + 1} +
+ )} + {index >= 3 && attendee.user.avatarUrl && ( + {attendee.user.username} + )} + {index >= 3 && !attendee.user.avatarUrl && ( +
+ + {attendee.user.username.charAt(0).toUpperCase()} + +
+ )} +
+
+

+ {attendee.user.firstName && attendee.user.lastName + ? `${attendee.user.firstName} ${attendee.user.lastName}` + : attendee.user.username} +

+ {attendee.contributionNote && ( +

{attendee.contributionNote}

+ )} +
+
+
+ + {attendee.litterCollected?.toFixed(1)} kg + +
+
+ ))} +
+ {attendees.filter(a => a.litterCollected && a.litterCollected > 0).length > 5 && ( + + )} +
+ )} + + {/* All Attendees */} + {attendees.length > 0 && ( +
+

+ All Attendees ({attendees.length}) +

+
+ {attendees.map((attendee) => ( +
+ {attendee.user.avatarUrl ? ( + {attendee.user.username} + ) : ( +
+ + {attendee.user.username.charAt(0).toUpperCase()} + +
+ )} + + {attendee.user.firstName && attendee.user.lastName + ? `${attendee.user.firstName} ${attendee.user.lastName}` + : attendee.user.username} + +
+ ))} +
+
+ )} + + {/* Event Photos */} + {photos.length > 0 && ( +
+

Event Photos

+
+ {photos.map((photo, index) => ( + {`Event window.open(photo, '_blank')} + /> + ))} +
+
+ )} + + {loading && ( +
+
+
+ )} +
+ ); +} diff --git a/src/components/event/EventCompletionForm.tsx b/src/components/event/EventCompletionForm.tsx index 36c5526..dc57a05 100644 --- a/src/components/event/EventCompletionForm.tsx +++ b/src/components/event/EventCompletionForm.tsx @@ -1,20 +1,34 @@ import { useState } from 'react'; import { PhotoUpload, type PhotoMetadata } from '@/components/report/PhotoUpload'; +import { AttendeeManagement } from './AttendeeManagement'; interface EventCompletionFormProps { eventId: string; - onSubmit: (data: { litterCollected: number; photos: string[] }) => Promise; + onSubmit: (data: { litterCollected: number; photos: string[]; attendees?: Array<{ + userId: string; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + }> }) => Promise; onCancel: () => void; isLoading?: boolean; } export function EventCompletionForm({ + eventId, onSubmit, onCancel, isLoading = false, }: EventCompletionFormProps) { + const [step, setStep] = useState<'attendees' | 'summary'>('attendees'); const [litterCollected, setLitterCollected] = useState(''); const [photos, setPhotos] = useState([]); + const [attendeeData, setAttendeeData] = useState>([]); const [errors, setErrors] = useState<{ litterCollected?: string }>({}); const validateForm = (): boolean => { @@ -33,6 +47,21 @@ export function EventCompletionForm({ return Object.keys(newErrors).length === 0; }; + const handleAttendeesSave = async (attendees: Array<{ + userId: string; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + }>) => { + setAttendeeData(attendees); + + // Calculate total litter from attendees + const totalFromAttendees = attendees.reduce((sum, a) => sum + (a.litterCollected || 0), 0); + setLitterCollected(totalFromAttendees.toFixed(1)); + + setStep('summary'); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -41,13 +70,12 @@ export function EventCompletionForm({ } try { - // In a real implementation, we would upload photos to the server - // For now, we'll just pass the preview URLs const photoUrls = photos.map(photo => photo.preview); await onSubmit({ litterCollected: parseFloat(litterCollected), photos: photoUrls, + attendees: attendeeData.length > 0 ? attendeeData : undefined, }); } catch (error) { console.error('Error completing event:', error); @@ -58,9 +86,29 @@ export function EventCompletionForm({ setPhotos(photoMetadata); }; + if (step === 'attendees') { + return ( + + ); + } + return (
-

Complete Event

+
+

Complete Event

+ +
{/* Litter Collected Input */} diff --git a/src/components/event/EventDetailModal.tsx b/src/components/event/EventDetailModal.tsx index 603b9f6..1e19d65 100644 --- a/src/components/event/EventDetailModal.tsx +++ b/src/components/event/EventDetailModal.tsx @@ -4,6 +4,7 @@ import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import type { CleanupEvent } from '@/types'; import { EventCompletionForm } from './EventCompletionForm'; +import { CompletedEventDetails } from './CompletedEventDetails'; // Fix for default marker icons import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'; @@ -24,7 +25,16 @@ interface EventDetailModalProps { onClose: () => void; onRegister: () => void; onUnregister: () => void; - onComplete: (data: { litterCollected: number; photos: string[] }) => Promise; + onComplete: (data: { + litterCollected: number; + photos: string[]; + attendees?: Array<{ + userId: string; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + }>; + }) => Promise; onCancel?: () => void; isLoading?: boolean; } @@ -140,129 +150,106 @@ export function EventDetailModal({ {/* Content - Scrollable */}
- {/* Event Details */} -
-
- - - -
-

Date & Time

-

- {formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)} -

-
-
+ {/* Event Details - Hide when showing completion form */} + {!(canComplete && showCompletionForm) && ( + <> +
+
+ + + +
+

Date & Time

+

+ {formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)} +

+
+
-
- - - -
-

Duration

-

{formatDuration(event.duration)}

-
-
+
+ + + +
+

Duration

+

{formatDuration(event.duration)}

+
+
-
- - - - -
-

Location

-

{event.locationName}

-

- {event.latitude.toFixed(6)}, {event.longitude.toFixed(6)} -

-
-
-
- - {/* Description */} -
-

Description

-

{event.description}

-
- - {/* Completed Event Info */} - {event.status === 'completed' && event.litterCollected !== undefined && ( -
-
- - - -
-

Event Completed

-

{event.litterCollected} kg of litter collected

+
+ + + + +
+

Location

+

{event.locationName}

+

+ {event.latitude.toFixed(6)}, {event.longitude.toFixed(6)} +

+
-
- )} - {/* Photos (if completed) */} - {event.status === 'completed' && event.photos && event.photos.length > 0 && ( -
-

Event Photos

-
- {event.photos.map((photo, index) => ( - {`Event - ))} + {/* Description */} +
+

Description

+

{event.description}

-
+ + {/* Completed Event Details */} + {event.status === 'completed' && event.litterCollected !== undefined && ( + + )} + + {/* Map - Temporarily removed for debugging */} + {/*
+

Event Location Map

+
+
+
+
*/} + )} - {/* Map - Temporarily removed for debugging */} - {/*
-

Event Location Map

-
-
+ {/* Completion Form for Organizers */} + {canComplete && showCompletionForm && ( +
+ { + await onComplete(data); + setShowCompletionForm(false); + }} + onCancel={() => setShowCompletionForm(false)} + isLoading={isLoading} + />
-
*/} + )}
- {/* Completion Form for Organizers */} - {canComplete && showCompletionForm && ( -
- { - await onComplete(data); - setShowCompletionForm(false); - }} - onCancel={() => setShowCompletionForm(false)} - isLoading={isLoading} - /> -
- )} - {/* Footer Actions */} - {event.status === 'upcoming' && ( + {event.status === 'upcoming' && !showCompletionForm && (
{canComplete ? (
diff --git a/src/components/event/index.ts b/src/components/event/index.ts index f0405ec..da2dd9e 100644 --- a/src/components/event/index.ts +++ b/src/components/event/index.ts @@ -4,4 +4,6 @@ export { EventList } from './EventList'; export { EventFilters } from './EventFilters'; export { EventDetailModal } from './EventDetailModal'; export { EventCompletionForm } from './EventCompletionForm'; +export { AttendeeManagement } from './AttendeeManagement'; +export { CompletedEventDetails } from './CompletedEventDetails'; export type { EventFilterState } from './EventFilters'; diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index 0a23a34..03cacd8 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -166,7 +166,16 @@ export function EventsPage() { } }; - const handleCompleteEvent = async (data: { litterCollected: number; photos: string[] }) => { + const handleCompleteEvent = async (data: { + litterCollected: number; + photos: string[]; + attendees?: Array<{ + userId: string; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + }>; + }) => { if (!selectedEvent) return; try { diff --git a/src/pages/UserProfilePage.tsx b/src/pages/UserProfilePage.tsx index e4a45b2..ccd8216 100644 --- a/src/pages/UserProfilePage.tsx +++ b/src/pages/UserProfilePage.tsx @@ -316,7 +316,7 @@ export function UserProfilePage() {
{/* Statistics and Badge */} -
+
{/* Reports Submitted */}
@@ -347,6 +347,22 @@ export function UserProfilePage() {
+ {/* Total Litter Collected */} +
+
+
+ + + +
+
+

Litter Collected

+

{profile.totalLitterCollected.toFixed(1)}

+

kg

+
+
+
+ {/* Impact Score */}
diff --git a/src/services/api.ts b/src/services/api.ts index d3ffa63..179dc28 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -46,11 +46,24 @@ export class ApiClient { }); if (!response.ok) { - const error: ApiError = await response.json(); + let error: ApiError; + try { + error = await response.json(); + } catch { + // If response is not JSON, create a generic error + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } throw new Error(error.message || 'An error occurred'); } - return response.json(); + // Handle empty responses (like DELETE) + const contentType = response.headers.get('content-type'); + if (contentType && contentType.includes('application/json')) { + return response.json(); + } + + // For non-JSON responses, return empty object + return {} as T; } async get(endpoint: string, options?: RequestInit): Promise { diff --git a/src/services/eventService.ts b/src/services/eventService.ts index 2c048c7..437a596 100644 --- a/src/services/eventService.ts +++ b/src/services/eventService.ts @@ -101,7 +101,43 @@ export const eventService = { }, // Complete event - async completeEvent(eventId: string, data: { litterCollected: number; photos?: string[] }): Promise { - return apiClient.post(`/events/${eventId}/complete`, data); + async completeEvent( + eventId: string, + data: { + litterCollected: number; + photos?: string[]; + attendees?: Array<{ + userId: string; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + }>; + } + ): Promise { + try { + // If attendees data is provided, update them first + if (data.attendees && data.attendees.length > 0) { + console.log('Updating attendees:', data.attendees); + await apiClient.post(`/events/${eventId}/attendees/bulk`, { + attendees: data.attendees, + }); + console.log('Attendees updated successfully'); + } + + // Then complete the event + console.log('Completing event with data:', { + litterCollected: data.litterCollected, + photos: data.photos, + }); + const result = await apiClient.post(`/events/${eventId}/complete`, { + litterCollected: data.litterCollected, + photos: data.photos, + }); + console.log('Event completed successfully:', result); + return result; + } catch (error) { + console.error('Error in completeEvent:', error); + throw error; + } }, }; diff --git a/src/services/userService.ts b/src/services/userService.ts index 8ef1ea2..3cb511f 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -5,6 +5,7 @@ import type { User, Report, CleanupEvent } from '@/types'; export interface UserProfile extends User { reportCount: number; eventCount: number; + totalLitterCollected: number; notificationEmail: boolean; notificationInApp: boolean; areasOfInterest: Array<{ diff --git a/src/types/index.ts b/src/types/index.ts index 72d732e..63539d1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -66,6 +66,23 @@ export interface CleanupEvent { updatedAt: Date; } +export interface EventAttendee { + id: string; + userId: string; + eventId: string; + registeredAt: Date; + attended: boolean; + litterCollected?: number; + contributionNote?: string; + user: { + id: string; + username: string; + firstName?: string; + lastName?: string; + avatarUrl?: string; + }; +} + export interface EnvironmentalConcern { id: string; reportId: string;