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:
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 { 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 */}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user