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:
2025-11-18 19:03:43 +00:00
parent 3d7b84f376
commit 3a6db92699
22 changed files with 1462 additions and 128 deletions

View 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>
);
}

View 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>
);
}

View File

@@ -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<void>;
onSubmit: (data: { litterCollected: number; photos: string[]; attendees?: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}> }) => Promise<void>;
onCancel: () => void;
isLoading?: boolean;
}
export function EventCompletionForm({
eventId,
onSubmit,
onCancel,
isLoading = false,
}: EventCompletionFormProps) {
const [step, setStep] = useState<'attendees' | 'summary'>('attendees');
const [litterCollected, setLitterCollected] = useState<string>('');
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 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 (
<AttendeeManagement
eventId={eventId}
onClose={onCancel}
onSave={handleAttendeesSave}
isLoading={isLoading}
/>
);
}
return (
<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">
{/* Litter Collected Input */}

View File

@@ -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<void>;
onComplete: (data: {
litterCollected: number;
photos: string[];
attendees?: Array<{
userId: string;
attended: boolean;
litterCollected?: number;
contributionNote?: string;
}>;
}) => Promise<void>;
onCancel?: () => void;
isLoading?: boolean;
}
@@ -140,129 +150,106 @@ export function EventDetailModal({
{/* Content - Scrollable */}
<div className="overflow-y-auto flex-1 bg-white">
<div className="px-6 py-6 space-y-6">
{/* Event Details */}
<div className="space-y-4">
<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">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
<div>
<p className="text-sm font-medium text-gray-900">Date & Time</p>
<p className="text-sm text-gray-600 mt-1">
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
</p>
</div>
</div>
{/* Event Details - Hide when showing completion form */}
{!(canComplete && showCompletionForm) && (
<>
<div className="space-y-4">
<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">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
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"
/>
</svg>
<div>
<p className="text-sm font-medium text-gray-900">Date & Time</p>
<p className="text-sm text-gray-600 mt-1">
{formatEventDate(event.scheduledDate)} at {formatEventTime(event.scheduledDate)}
</p>
</div>
</div>
<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">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p className="text-sm font-medium text-gray-900">Duration</p>
<p className="text-sm text-gray-600 mt-1">{formatDuration(event.duration)}</p>
</div>
</div>
<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">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
<div>
<p className="text-sm font-medium text-gray-900">Duration</p>
<p className="text-sm text-gray-600 mt-1">{formatDuration(event.duration)}</p>
</div>
</div>
<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">
<path
strokeLinecap="round"
strokeLinejoin="round"
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"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div>
<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-xs text-gray-500 mt-1">
{event.latitude.toFixed(6)}, {event.longitude.toFixed(6)}
</p>
</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 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">
<path
strokeLinecap="round"
strokeLinejoin="round"
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"
/>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<div>
<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-xs text-gray-500 mt-1">
{event.latitude.toFixed(6)}, {event.longitude.toFixed(6)}
</p>
</div>
</div>
</div>
</div>
)}
{/* Photos (if completed) */}
{event.status === 'completed' && event.photos && event.photos.length > 0 && (
<div>
<h3 className="text-sm font-medium text-gray-900 mb-2">Event Photos</h3>
<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"
/>
))}
{/* 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>
</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 */}
{/* <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" />
{/* Completion Form for Organizers */}
{canComplete && showCompletionForm && (
<div className="mt-6">
<EventCompletionForm
eventId={event.id}
onSubmit={async (data) => {
await onComplete(data);
setShowCompletionForm(false);
}}
onCancel={() => setShowCompletionForm(false)}
isLoading={isLoading}
/>
</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 */}
{event.status === 'upcoming' && (
{event.status === 'upcoming' && !showCompletionForm && (
<div className="bg-gray-50 px-6 py-4 border-t border-gray-200 flex-shrink-0">
{canComplete ? (
<div className="flex gap-3">

View File

@@ -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';

View File

@@ -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 {

View File

@@ -316,7 +316,7 @@ export function UserProfilePage() {
</div>
{/* 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 */}
<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">
@@ -347,6 +347,22 @@ export function UserProfilePage() {
</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 */}
<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">

View File

@@ -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<T>(endpoint: string, options?: RequestInit): Promise<T> {

View File

@@ -101,7 +101,43 @@ export const eventService = {
},
// Complete event
async completeEvent(eventId: string, data: { litterCollected: number; photos?: string[] }): Promise<CleanupEvent> {
return apiClient.post<CleanupEvent>(`/events/${eventId}/complete`, data);
async completeEvent(
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;
}
},
};

View File

@@ -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<{

View File

@@ -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;