Add total litter collected display to user profile
- Updated backend userService to calculate total litter collected across all attended events - Added totalLitterCollected field to UserProfile interface - Added new statistics card showing total litter collected in kg - Updated profile grid layout to accommodate 5 statistics cards - Displays litter collected with 1 decimal place precision
This commit is contained in:
214
ATTENDEE_TRACKING_FEATURE.md
Normal file
214
ATTENDEE_TRACKING_FEATURE.md
Normal file
@@ -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
|
||||||
|
<AttendeeManagement
|
||||||
|
eventId={eventId}
|
||||||
|
onClose={handleClose}
|
||||||
|
onSave={handleSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<CompletedEventDetails
|
||||||
|
eventId={event.id}
|
||||||
|
totalLitterCollected={event.litterCollected}
|
||||||
|
photos={event.photos}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
54
START_SERVERS.md
Normal file
54
START_SERVERS.md
Normal file
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE `event_registrations`
|
||||||
|
ADD COLUMN `litter_collected` DECIMAL(10, 2) NULL,
|
||||||
|
ADD COLUMN `contribution_note` TEXT NULL;
|
||||||
@@ -96,13 +96,15 @@ model Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model EventRegistration {
|
model EventRegistration {
|
||||||
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
|
id String @id @default(dbgenerated("(uuid())")) @db.VarChar(36)
|
||||||
userId String @map("user_id") @db.VarChar(36)
|
userId String @map("user_id") @db.VarChar(36)
|
||||||
eventId String @map("event_id") @db.VarChar(36)
|
eventId String @map("event_id") @db.VarChar(36)
|
||||||
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
|
registeredAt DateTime? @default(now()) @map("registered_at") @db.Timestamp(0)
|
||||||
attended Boolean? @default(false)
|
attended Boolean? @default(false)
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_1")
|
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
|
||||||
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "event_registrations_ibfk_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")
|
@@unique([userId, eventId], map: "unique_user_event")
|
||||||
@@index([eventId], map: "event_id")
|
@@index([eventId], map: "event_id")
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const router = Router();
|
|||||||
router.get('/', eventController.getEvents);
|
router.get('/', eventController.getEvents);
|
||||||
router.get('/:id', eventController.getEvent);
|
router.get('/:id', eventController.getEvent);
|
||||||
router.get('/:id/registrations', eventController.getEventRegistrations);
|
router.get('/:id/registrations', eventController.getEventRegistrations);
|
||||||
|
router.get('/:id/attendees', eventController.getEventAttendees);
|
||||||
|
|
||||||
// Protected routes (require authentication)
|
// Protected routes (require authentication)
|
||||||
router.post('/', requireAuth, eventController.createEvent);
|
router.post('/', requireAuth, eventController.createEvent);
|
||||||
@@ -17,5 +18,7 @@ router.post('/:id/register', requireAuth, eventController.registerForEvent);
|
|||||||
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
|
router.delete('/:id/register', requireAuth, eventController.unregisterFromEvent);
|
||||||
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
|
router.get('/:id/registration-status', requireAuth, eventController.getRegistrationStatus);
|
||||||
router.post('/:id/complete', requireAuth, eventController.completeEvent);
|
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;
|
export default router;
|
||||||
|
|||||||
57
server/src/scripts/applyMigration.ts
Normal file
57
server/src/scripts/applyMigration.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
89
server/src/scripts/testAttendees.ts
Normal file
89
server/src/scripts/testAttendees.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
43
server/src/scripts/verifyMigration.ts
Normal file
43
server/src/scripts/verifyMigration.ts
Normal file
@@ -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));
|
||||||
@@ -372,4 +372,111 @@ export const eventService = {
|
|||||||
updatedAt: event.updatedAt,
|
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;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -97,6 +97,17 @@ export class UserService {
|
|||||||
organizedEvents: true,
|
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 {
|
return {
|
||||||
...user,
|
...user,
|
||||||
areasOfInterest,
|
areasOfInterest,
|
||||||
reportCount: user._count.reports,
|
reportCount: user._count.reports,
|
||||||
eventCount: user._count.eventRegistrations,
|
eventCount: user._count.eventRegistrations,
|
||||||
|
totalLitterCollected,
|
||||||
stats: {
|
stats: {
|
||||||
reportsSubmitted: user._count.reports,
|
reportsSubmitted: user._count.reports,
|
||||||
eventsAttended: user._count.eventRegistrations,
|
eventsAttended: user._count.eventRegistrations,
|
||||||
|
|||||||
277
src/components/event/AttendeeManagement.tsx
Normal file
277
src/components/event/AttendeeManagement.tsx
Normal file
@@ -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<void>;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AttendeeManagement({
|
||||||
|
eventId,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
isLoading = false,
|
||||||
|
}: AttendeeManagementProps) {
|
||||||
|
const [attendees, setAttendees] = useState<EventAttendee[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editedAttendees, setEditedAttendees] = useState<Map<string, Partial<EventAttendee>>>(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 (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-red-600">{error}</p>
|
||||||
|
<button
|
||||||
|
onClick={fetchAttendees}
|
||||||
|
className="mt-4 text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
Try Again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attendees.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<p className="text-gray-500">No registered attendees for this event</p>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="mt-4 px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 flex flex-col max-h-[80vh]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Manage Attendees</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
Mark who attended and track individual contributions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Summary Stats */}
|
||||||
|
<div className="px-6 py-4 bg-gray-50 border-b border-gray-200 flex-shrink-0">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Registered</p>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{attendees.length}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Attended</p>
|
||||||
|
<p className="text-2xl font-bold text-green-600">{getAttendedCount()}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-gray-500">Total Litter (kg)</p>
|
||||||
|
<p className="text-2xl font-bold text-blue-600">{getTotalLitter().toFixed(1)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Attendee List */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-gray-50 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Attendee
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Attended
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Litter (kg)
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Note
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{attendees.map((attendee) => (
|
||||||
|
<tr key={attendee.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="flex items-center">
|
||||||
|
{attendee.user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={attendee.user.avatarUrl}
|
||||||
|
alt={attendee.user.username}
|
||||||
|
className="h-8 w-8 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-8 w-8 rounded-full bg-blue-100 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-blue-600">
|
||||||
|
{attendee.user.username.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="ml-3">
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{attendee.user.firstName && attendee.user.lastName
|
||||||
|
? `${attendee.user.firstName} ${attendee.user.lastName}`
|
||||||
|
: attendee.user.username}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">@{attendee.user.username}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={getAttendeeValue(attendee, 'attended') as boolean}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.1"
|
||||||
|
min="0"
|
||||||
|
value={getAttendeeValue(attendee, 'litterCollected') as number || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getAttendeeValue(attendee, 'contributionNote') as string || ''}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="px-6 py-4 bg-gray-50 border-t border-gray-200 flex gap-3 flex-shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading || editedAttendees.size === 0}
|
||||||
|
className="flex-1 bg-blue-600 text-white py-2 px-4 rounded-lg hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
{isLoading ? 'Saving...' : `Save Changes${editedAttendees.size > 0 ? ` (${editedAttendees.size})` : ''}`}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isLoading}
|
||||||
|
className="px-6 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors font-medium"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
src/components/event/CompletedEventDetails.tsx
Normal file
209
src/components/event/CompletedEventDetails.tsx
Normal file
@@ -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<EventAttendee[]>([]);
|
||||||
|
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 (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Summary Card */}
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<svg className="h-6 w-6 text-green-600 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-green-900">Event Completed</p>
|
||||||
|
<p className="text-sm text-green-700 mt-1">
|
||||||
|
{totalLitterCollected} kg of litter collected by {attendedCount} {attendedCount === 1 ? 'volunteer' : 'volunteers'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top Contributors */}
|
||||||
|
{topContributors.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-3">Top Contributors</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topContributors.map((attendee, index) => (
|
||||||
|
<div
|
||||||
|
key={attendee.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex items-center flex-1">
|
||||||
|
<div className="flex-shrink-0 mr-3">
|
||||||
|
{index < 3 && (
|
||||||
|
<div className={`w-8 h-8 rounded-full flex items-center justify-center ${
|
||||||
|
index === 0 ? 'bg-yellow-100 text-yellow-700' :
|
||||||
|
index === 1 ? 'bg-gray-200 text-gray-700' :
|
||||||
|
'bg-orange-100 text-orange-700'
|
||||||
|
}`}>
|
||||||
|
<span className="text-sm font-bold">#{index + 1}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{index >= 3 && attendee.user.avatarUrl && (
|
||||||
|
<img
|
||||||
|
src={attendee.user.avatarUrl}
|
||||||
|
alt={attendee.user.username}
|
||||||
|
className="w-8 h-8 rounded-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{index >= 3 && !attendee.user.avatarUrl && (
|
||||||
|
<div className="w-8 h-8 rounded-full bg-blue-100 flex items-center justify-center">
|
||||||
|
<span className="text-sm font-medium text-blue-600">
|
||||||
|
{attendee.user.username.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-gray-900 truncate">
|
||||||
|
{attendee.user.firstName && attendee.user.lastName
|
||||||
|
? `${attendee.user.firstName} ${attendee.user.lastName}`
|
||||||
|
: attendee.user.username}
|
||||||
|
</p>
|
||||||
|
{attendee.contributionNote && (
|
||||||
|
<p className="text-xs text-gray-500 truncate">{attendee.contributionNote}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-4 flex-shrink-0">
|
||||||
|
<span className="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800">
|
||||||
|
{attendee.litterCollected?.toFixed(1)} kg
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{attendees.filter(a => a.litterCollected && a.litterCollected > 0).length > 5 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAllAttendees(!showAllAttendees)}
|
||||||
|
className="mt-3 text-sm text-blue-600 hover:text-blue-700 font-medium"
|
||||||
|
>
|
||||||
|
{showAllAttendees ? 'Show Less' : `Show All ${attendees.filter(a => a.litterCollected && a.litterCollected > 0).length} Contributors`}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* All Attendees */}
|
||||||
|
{attendees.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-3">
|
||||||
|
All Attendees ({attendees.length})
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{attendees.map((attendee) => (
|
||||||
|
<div
|
||||||
|
key={attendee.id}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 rounded-full"
|
||||||
|
title={attendee.user.username}
|
||||||
|
>
|
||||||
|
{attendee.user.avatarUrl ? (
|
||||||
|
<img
|
||||||
|
src={attendee.user.avatarUrl}
|
||||||
|
alt={attendee.user.username}
|
||||||
|
className="w-5 h-5 rounded-full"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center">
|
||||||
|
<span className="text-xs font-medium text-blue-600">
|
||||||
|
{attendee.user.username.charAt(0).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<span className="text-sm text-gray-700">
|
||||||
|
{attendee.user.firstName && attendee.user.lastName
|
||||||
|
? `${attendee.user.firstName} ${attendee.user.lastName}`
|
||||||
|
: attendee.user.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Event Photos */}
|
||||||
|
{photos.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-gray-900 mb-3">Event Photos</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{photos.map((photo, index) => (
|
||||||
|
<img
|
||||||
|
key={index}
|
||||||
|
src={photo}
|
||||||
|
alt={`Event photo ${index + 1}`}
|
||||||
|
className="w-full h-32 object-cover rounded-lg hover:opacity-90 transition-opacity cursor-pointer"
|
||||||
|
onClick={() => window.open(photo, '_blank')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,34 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { PhotoUpload, type PhotoMetadata } from '@/components/report/PhotoUpload';
|
import { PhotoUpload, type PhotoMetadata } from '@/components/report/PhotoUpload';
|
||||||
|
import { AttendeeManagement } from './AttendeeManagement';
|
||||||
|
|
||||||
interface EventCompletionFormProps {
|
interface EventCompletionFormProps {
|
||||||
eventId: string;
|
eventId: string;
|
||||||
onSubmit: (data: { litterCollected: number; photos: string[] }) => Promise<void>;
|
onSubmit: (data: { litterCollected: number; photos: string[]; attendees?: Array<{
|
||||||
|
userId: string;
|
||||||
|
attended: boolean;
|
||||||
|
litterCollected?: number;
|
||||||
|
contributionNote?: string;
|
||||||
|
}> }) => Promise<void>;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function EventCompletionForm({
|
export function EventCompletionForm({
|
||||||
|
eventId,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
}: EventCompletionFormProps) {
|
}: EventCompletionFormProps) {
|
||||||
|
const [step, setStep] = useState<'attendees' | 'summary'>('attendees');
|
||||||
const [litterCollected, setLitterCollected] = useState<string>('');
|
const [litterCollected, setLitterCollected] = useState<string>('');
|
||||||
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
|
const [photos, setPhotos] = useState<PhotoMetadata[]>([]);
|
||||||
|
const [attendeeData, setAttendeeData] = useState<Array<{
|
||||||
|
userId: string;
|
||||||
|
attended: boolean;
|
||||||
|
litterCollected?: number;
|
||||||
|
contributionNote?: string;
|
||||||
|
}>>([]);
|
||||||
const [errors, setErrors] = useState<{ litterCollected?: string }>({});
|
const [errors, setErrors] = useState<{ litterCollected?: string }>({});
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
@@ -33,6 +47,21 @@ export function EventCompletionForm({
|
|||||||
return Object.keys(newErrors).length === 0;
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
@@ -41,13 +70,12 @@ export function EventCompletionForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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);
|
const photoUrls = photos.map(photo => photo.preview);
|
||||||
|
|
||||||
await onSubmit({
|
await onSubmit({
|
||||||
litterCollected: parseFloat(litterCollected),
|
litterCollected: parseFloat(litterCollected),
|
||||||
photos: photoUrls,
|
photos: photoUrls,
|
||||||
|
attendees: attendeeData.length > 0 ? attendeeData : undefined,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error completing event:', error);
|
console.error('Error completing event:', error);
|
||||||
@@ -58,9 +86,29 @@ export function EventCompletionForm({
|
|||||||
setPhotos(photoMetadata);
|
setPhotos(photoMetadata);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (step === 'attendees') {
|
||||||
|
return (
|
||||||
|
<AttendeeManagement
|
||||||
|
eventId={eventId}
|
||||||
|
onClose={onCancel}
|
||||||
|
onSave={handleAttendeesSave}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6">
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Complete Event</h3>
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900">Complete Event</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => setStep('attendees')}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
← Back to Attendees
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
{/* Litter Collected Input */}
|
{/* Litter Collected Input */}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import L from 'leaflet';
|
|||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import type { CleanupEvent } from '@/types';
|
import type { CleanupEvent } from '@/types';
|
||||||
import { EventCompletionForm } from './EventCompletionForm';
|
import { EventCompletionForm } from './EventCompletionForm';
|
||||||
|
import { CompletedEventDetails } from './CompletedEventDetails';
|
||||||
|
|
||||||
// Fix for default marker icons
|
// Fix for default marker icons
|
||||||
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
|
||||||
@@ -24,7 +25,16 @@ interface EventDetailModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRegister: () => void;
|
onRegister: () => void;
|
||||||
onUnregister: () => void;
|
onUnregister: () => void;
|
||||||
onComplete: (data: { litterCollected: number; photos: string[] }) => Promise<void>;
|
onComplete: (data: {
|
||||||
|
litterCollected: number;
|
||||||
|
photos: string[];
|
||||||
|
attendees?: Array<{
|
||||||
|
userId: string;
|
||||||
|
attended: boolean;
|
||||||
|
litterCollected?: number;
|
||||||
|
contributionNote?: string;
|
||||||
|
}>;
|
||||||
|
}) => Promise<void>;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
@@ -140,129 +150,106 @@ export function EventDetailModal({
|
|||||||
{/* Content - Scrollable */}
|
{/* Content - Scrollable */}
|
||||||
<div className="overflow-y-auto flex-1 bg-white">
|
<div className="overflow-y-auto flex-1 bg-white">
|
||||||
<div className="px-6 py-6 space-y-6">
|
<div className="px-6 py-6 space-y-6">
|
||||||
{/* Event Details */}
|
{/* Event Details - Hide when showing completion form */}
|
||||||
<div className="space-y-4">
|
{!(canComplete && showCompletionForm) && (
|
||||||
<div className="flex items-start">
|
<>
|
||||||
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<div className="space-y-4">
|
||||||
<path
|
<div className="flex items-start">
|
||||||
strokeLinecap="round"
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
strokeLinejoin="round"
|
<path
|
||||||
strokeWidth={2}
|
strokeLinecap="round"
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
strokeLinejoin="round"
|
||||||
/>
|
strokeWidth={2}
|
||||||
</svg>
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"
|
||||||
<div>
|
/>
|
||||||
<p className="text-sm font-medium text-gray-900">Date & Time</p>
|
</svg>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<div>
|
||||||
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
<p className="text-sm font-medium text-gray-900">Date & Time</p>
|
||||||
</p>
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
</div>
|
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
|
||||||
</div>
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">Duration</p>
|
<p className="text-sm font-medium text-gray-900">Duration</p>
|
||||||
<p className="text-sm text-gray-600 mt-1">{formatDuration(event.duration)}</p>
|
<p className="text-sm text-gray-600 mt-1">{formatDuration(event.duration)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start">
|
<div className="flex items-start">
|
||||||
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="h-6 w-6 text-gray-400 mr-3 mt-0.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path
|
<path
|
||||||
strokeLinecap="round"
|
strokeLinecap="round"
|
||||||
strokeLinejoin="round"
|
strokeLinejoin="round"
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||||
/>
|
/>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">Location</p>
|
<p className="text-sm font-medium text-gray-900">Location</p>
|
||||||
<p className="text-sm text-gray-600 mt-1">{event.locationName}</p>
|
<p className="text-sm text-gray-600 mt-1">{event.locationName}</p>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
{event.latitude.toFixed(6)}, {event.longitude.toFixed(6)}
|
{event.latitude.toFixed(6)}, {event.longitude.toFixed(6)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Description</h3>
|
|
||||||
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Completed Event Info */}
|
|
||||||
{event.status === 'completed' && event.litterCollected !== undefined && (
|
|
||||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<svg className="h-6 w-6 text-green-600 mr-3 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-green-900">Event Completed</p>
|
|
||||||
<p className="text-sm text-green-700 mt-1">{event.litterCollected} kg of litter collected</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Photos (if completed) */}
|
{/* Description */}
|
||||||
{event.status === 'completed' && event.photos && event.photos.length > 0 && (
|
<div>
|
||||||
<div>
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Description</h3>
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Photos</h3>
|
<p className="text-sm text-gray-600 whitespace-pre-wrap">{event.description}</p>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
|
||||||
{event.photos.map((photo, index) => (
|
|
||||||
<img
|
|
||||||
key={index}
|
|
||||||
src={photo}
|
|
||||||
alt={`Event photo ${index + 1}`}
|
|
||||||
className="w-full h-32 object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Completed Event Details */}
|
||||||
|
{event.status === 'completed' && event.litterCollected !== undefined && (
|
||||||
|
<CompletedEventDetails
|
||||||
|
eventId={event.id}
|
||||||
|
totalLitterCollected={event.litterCollected}
|
||||||
|
photos={event.photos}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Map - Temporarily removed for debugging */}
|
||||||
|
{/* <div>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Location Map</h3>
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div ref={mapRef} className="w-full h-64" />
|
||||||
|
</div>
|
||||||
|
</div> */}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Map - Temporarily removed for debugging */}
|
{/* Completion Form for Organizers */}
|
||||||
{/* <div>
|
{canComplete && showCompletionForm && (
|
||||||
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Location Map</h3>
|
<div className="mt-6">
|
||||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
<EventCompletionForm
|
||||||
<div ref={mapRef} className="w-full h-64" />
|
eventId={event.id}
|
||||||
|
onSubmit={async (data) => {
|
||||||
|
await onComplete(data);
|
||||||
|
setShowCompletionForm(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowCompletionForm(false)}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div> */}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Completion Form for Organizers */}
|
|
||||||
{canComplete && showCompletionForm && (
|
|
||||||
<div className="px-6 pb-6">
|
|
||||||
<EventCompletionForm
|
|
||||||
eventId={event.id}
|
|
||||||
onSubmit={async (data) => {
|
|
||||||
await onComplete(data);
|
|
||||||
setShowCompletionForm(false);
|
|
||||||
}}
|
|
||||||
onCancel={() => setShowCompletionForm(false)}
|
|
||||||
isLoading={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
{event.status === 'upcoming' && (
|
{event.status === 'upcoming' && !showCompletionForm && (
|
||||||
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
|
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
|
||||||
{canComplete ? (
|
{canComplete ? (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
|
|||||||
@@ -4,4 +4,6 @@ export { EventList } from './EventList';
|
|||||||
export { EventFilters } from './EventFilters';
|
export { EventFilters } from './EventFilters';
|
||||||
export { EventDetailModal } from './EventDetailModal';
|
export { EventDetailModal } from './EventDetailModal';
|
||||||
export { EventCompletionForm } from './EventCompletionForm';
|
export { EventCompletionForm } from './EventCompletionForm';
|
||||||
|
export { AttendeeManagement } from './AttendeeManagement';
|
||||||
|
export { CompletedEventDetails } from './CompletedEventDetails';
|
||||||
export type { EventFilterState } from './EventFilters';
|
export type { EventFilterState } from './EventFilters';
|
||||||
|
|||||||
@@ -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;
|
if (!selectedEvent) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export function UserProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Statistics and Badge */}
|
{/* Statistics and Badge */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mt-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-4 mt-6">
|
||||||
{/* Reports Submitted */}
|
{/* Reports Submitted */}
|
||||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-blue-200">
|
<div className="bg-gradient-to-br from-blue-50 to-blue-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-blue-200">
|
||||||
<div className="flex flex-col items-center text-center space-y-3">
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
@@ -347,6 +347,22 @@ export function UserProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Total Litter Collected */}
|
||||||
|
<div className="bg-gradient-to-br from-emerald-50 to-emerald-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-emerald-200">
|
||||||
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
<div className="bg-emerald-200 rounded-full p-3">
|
||||||
|
<svg className="w-8 h-8 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-emerald-700 mb-1">Litter Collected</p>
|
||||||
|
<p className="text-4xl font-bold text-emerald-600">{profile.totalLitterCollected.toFixed(1)}</p>
|
||||||
|
<p className="text-xs text-emerald-600 font-medium">kg</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Impact Score */}
|
{/* Impact Score */}
|
||||||
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-purple-200">
|
<div className="bg-gradient-to-br from-purple-50 to-purple-100 rounded-xl p-6 shadow-sm hover:shadow-md transition-shadow border border-purple-200">
|
||||||
<div className="flex flex-col items-center text-center space-y-3">
|
<div className="flex flex-col items-center text-center space-y-3">
|
||||||
|
|||||||
@@ -46,11 +46,24 @@ export class ApiClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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');
|
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<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||||
|
|||||||
@@ -101,7 +101,43 @@ export const eventService = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Complete event
|
// Complete event
|
||||||
async completeEvent(eventId: string, data: { litterCollected: number; photos?: string[] }): Promise<CleanupEvent> {
|
async completeEvent(
|
||||||
return apiClient.post<CleanupEvent>(`/events/${eventId}/complete`, data);
|
eventId: string,
|
||||||
|
data: {
|
||||||
|
litterCollected: number;
|
||||||
|
photos?: string[];
|
||||||
|
attendees?: Array<{
|
||||||
|
userId: string;
|
||||||
|
attended: boolean;
|
||||||
|
litterCollected?: number;
|
||||||
|
contributionNote?: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
): Promise<CleanupEvent> {
|
||||||
|
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<CleanupEvent>(`/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;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { User, Report, CleanupEvent } from '@/types';
|
|||||||
export interface UserProfile extends User {
|
export interface UserProfile extends User {
|
||||||
reportCount: number;
|
reportCount: number;
|
||||||
eventCount: number;
|
eventCount: number;
|
||||||
|
totalLitterCollected: number;
|
||||||
notificationEmail: boolean;
|
notificationEmail: boolean;
|
||||||
notificationInApp: boolean;
|
notificationInApp: boolean;
|
||||||
areasOfInterest: Array<{
|
areasOfInterest: Array<{
|
||||||
|
|||||||
@@ -66,6 +66,23 @@ export interface CleanupEvent {
|
|||||||
updatedAt: Date;
|
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 {
|
export interface EnvironmentalConcern {
|
||||||
id: string;
|
id: string;
|
||||||
reportId: string;
|
reportId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user