Add equipment management to events
- Add equipmentProvided and requiredItems fields to Event model - Update event creation form with equipment section - Allow organizers to specify if equipment is provided or BYO - Display required items list in event details - Update database schema and run migration
This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
-- Add equipment fields to events table
|
||||||
|
ALTER TABLE `events` ADD COLUMN `equipment_provided` BOOLEAN DEFAULT false AFTER `photos`;
|
||||||
|
ALTER TABLE `events` ADD COLUMN `required_items` JSON AFTER `equipment_provided`;
|
||||||
@@ -83,6 +83,8 @@ model Event {
|
|||||||
participantCount Int? @default(0) @map("participant_count")
|
participantCount Int? @default(0) @map("participant_count")
|
||||||
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
|
litterCollected Decimal? @map("litter_collected") @db.Decimal(10, 2)
|
||||||
photos Json?
|
photos Json?
|
||||||
|
equipmentProvided Boolean? @default(false) @map("equipment_provided")
|
||||||
|
requiredItems Json? @map("required_items")
|
||||||
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
createdAt DateTime? @default(now()) @map("created_at") @db.Timestamp(0)
|
||||||
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
|
updatedAt DateTime? @default(now()) @updatedAt @map("updated_at") @db.Timestamp(0)
|
||||||
registrations EventRegistration[]
|
registrations EventRegistration[]
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export const eventController = {
|
|||||||
// Find or create user in database
|
// Find or create user in database
|
||||||
const userId = await findOrCreateUser(clerkId);
|
const userId = await findOrCreateUser(clerkId);
|
||||||
|
|
||||||
const { title, description, latitude, longitude, locationName, scheduledDate, duration } = req.body;
|
const { title, description, latitude, longitude, locationName, scheduledDate, duration, equipmentProvided, requiredItems } = req.body;
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!title || !description || !latitude || !longitude || !locationName || !scheduledDate || !duration) {
|
if (!title || !description || !latitude || !longitude || !locationName || !scheduledDate || !duration) {
|
||||||
@@ -129,6 +129,8 @@ export const eventController = {
|
|||||||
locationName,
|
locationName,
|
||||||
scheduledDate: new Date(scheduledDate),
|
scheduledDate: new Date(scheduledDate),
|
||||||
duration: parseInt(duration),
|
duration: parseInt(duration),
|
||||||
|
equipmentProvided: equipmentProvided !== undefined ? Boolean(equipmentProvided) : false,
|
||||||
|
requiredItems: Array.isArray(requiredItems) ? requiredItems : [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const event = await eventService.createEvent(eventData);
|
const event = await eventService.createEvent(eventData);
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ interface CreateEventData {
|
|||||||
locationName: string;
|
locationName: string;
|
||||||
scheduledDate: Date;
|
scheduledDate: Date;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
equipmentProvided?: boolean;
|
||||||
|
requiredItems?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateEventData {
|
interface UpdateEventData {
|
||||||
@@ -31,6 +33,8 @@ interface UpdateEventData {
|
|||||||
status?: 'upcoming' | 'completed' | 'cancelled';
|
status?: 'upcoming' | 'completed' | 'cancelled';
|
||||||
litterCollected?: number;
|
litterCollected?: number;
|
||||||
photos?: string[];
|
photos?: string[];
|
||||||
|
equipmentProvided?: boolean;
|
||||||
|
requiredItems?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventService = {
|
export const eventService = {
|
||||||
@@ -93,6 +97,8 @@ export const eventService = {
|
|||||||
participantCount: event._count.registrations,
|
participantCount: event._count.registrations,
|
||||||
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
||||||
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined,
|
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined,
|
||||||
|
equipmentProvided: (event as any).equipmentProvided || false,
|
||||||
|
requiredItems: (event as any).requiredItems ? (Array.isArray((event as any).requiredItems) ? (event as any).requiredItems : []) : [],
|
||||||
createdAt: event.createdAt,
|
createdAt: event.createdAt,
|
||||||
updatedAt: event.updatedAt,
|
updatedAt: event.updatedAt,
|
||||||
}));
|
}));
|
||||||
@@ -137,6 +143,8 @@ export const eventService = {
|
|||||||
participantCount: event._count.registrations,
|
participantCount: event._count.registrations,
|
||||||
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
litterCollected: event.litterCollected ? parseFloat(event.litterCollected.toString()) : undefined,
|
||||||
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined,
|
photos: event.photos ? (Array.isArray(event.photos) ? event.photos : []) : undefined,
|
||||||
|
equipmentProvided: (event as any).equipmentProvided || false,
|
||||||
|
requiredItems: (event as any).requiredItems ? (Array.isArray((event as any).requiredItems) ? (event as any).requiredItems : []) : [],
|
||||||
createdAt: event.createdAt,
|
createdAt: event.createdAt,
|
||||||
updatedAt: event.updatedAt,
|
updatedAt: event.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -145,16 +153,18 @@ export const eventService = {
|
|||||||
async createEvent(data: CreateEventData) {
|
async createEvent(data: CreateEventData) {
|
||||||
// Create point geometry for spatial queries
|
// Create point geometry for spatial queries
|
||||||
const locationPoint = `POINT(${data.longitude} ${data.latitude})`;
|
const locationPoint = `POINT(${data.longitude} ${data.latitude})`;
|
||||||
|
const equipmentProvided = data.equipmentProvided !== undefined ? data.equipmentProvided : false;
|
||||||
|
const requiredItems = data.requiredItems && data.requiredItems.length > 0 ? JSON.stringify(data.requiredItems) : null;
|
||||||
|
|
||||||
const event = await prisma.$executeRaw`
|
const event = await prisma.$executeRaw`
|
||||||
INSERT INTO events (
|
INSERT INTO events (
|
||||||
id, organizer_id, title, description, location, latitude, longitude,
|
id, organizer_id, title, description, location, latitude, longitude,
|
||||||
location_name, scheduled_date, duration, status, participant_count
|
location_name, scheduled_date, duration, status, participant_count, equipment_provided, required_items
|
||||||
) VALUES (
|
) VALUES (
|
||||||
UUID(), ${data.organizerId}, ${data.title}, ${data.description},
|
UUID(), ${data.organizerId}, ${data.title}, ${data.description},
|
||||||
ST_GeomFromText(${locationPoint}, 4326),
|
ST_GeomFromText(${locationPoint}, 4326),
|
||||||
${data.latitude}, ${data.longitude}, ${data.locationName},
|
${data.latitude}, ${data.longitude}, ${data.locationName},
|
||||||
${data.scheduledDate}, ${data.duration}, 'upcoming', 0
|
${data.scheduledDate}, ${data.duration}, 'upcoming', 0, ${equipmentProvided}, ${requiredItems}
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -195,6 +205,8 @@ export const eventService = {
|
|||||||
participantCount: createdEvent._count.registrations,
|
participantCount: createdEvent._count.registrations,
|
||||||
litterCollected: createdEvent.litterCollected ? parseFloat(createdEvent.litterCollected.toString()) : undefined,
|
litterCollected: createdEvent.litterCollected ? parseFloat(createdEvent.litterCollected.toString()) : undefined,
|
||||||
photos: createdEvent.photos ? (Array.isArray(createdEvent.photos) ? createdEvent.photos : []) : undefined,
|
photos: createdEvent.photos ? (Array.isArray(createdEvent.photos) ? createdEvent.photos : []) : undefined,
|
||||||
|
equipmentProvided: (createdEvent as any).equipmentProvided || false,
|
||||||
|
requiredItems: (createdEvent as any).requiredItems ? (Array.isArray((createdEvent as any).requiredItems) ? (createdEvent as any).requiredItems : []) : [],
|
||||||
createdAt: createdEvent.createdAt,
|
createdAt: createdEvent.createdAt,
|
||||||
updatedAt: createdEvent.updatedAt,
|
updatedAt: createdEvent.updatedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -186,6 +186,54 @@ export function EventDetailModal({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Equipment Information */}
|
||||||
|
<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="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">Equipment</p>
|
||||||
|
{event.equipmentProvided ? (
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
✓ Equipment Provided
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
All necessary cleanup equipment will be provided by the organizer.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800">
|
||||||
|
Bring Your Own
|
||||||
|
</span>
|
||||||
|
{event.requiredItems && event.requiredItems.length > 0 ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<p className="text-sm text-gray-600 mb-2">Please bring the following items:</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{event.requiredItems.map((item, index) => (
|
||||||
|
<li key={index} className="text-sm text-gray-700 flex items-center">
|
||||||
|
<span className="text-blue-600 mr-2">•</span>
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-600 mt-2">
|
||||||
|
Participants should bring their own cleanup equipment.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</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
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface EventFormData {
|
|||||||
scheduledDate: string;
|
scheduledDate: string;
|
||||||
scheduledTime: string;
|
scheduledTime: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
|
equipmentProvided: boolean;
|
||||||
|
requiredItems: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EventFormProps {
|
interface EventFormProps {
|
||||||
@@ -44,8 +46,12 @@ export function EventForm({ onSubmit, onCancel, initialData, isSubmitting = fals
|
|||||||
scheduledDate: initialData?.scheduledDate || '',
|
scheduledDate: initialData?.scheduledDate || '',
|
||||||
scheduledTime: initialData?.scheduledTime || '',
|
scheduledTime: initialData?.scheduledTime || '',
|
||||||
duration: initialData?.duration || 120,
|
duration: initialData?.duration || 120,
|
||||||
|
equipmentProvided: initialData?.equipmentProvided || false,
|
||||||
|
requiredItems: initialData?.requiredItems || [],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [newItem, setNewItem] = useState('');
|
||||||
|
|
||||||
const [selectedLocation, setSelectedLocation] = useState<Coordinates | null>(
|
const [selectedLocation, setSelectedLocation] = useState<Coordinates | null>(
|
||||||
initialData?.latitude && initialData?.longitude
|
initialData?.latitude && initialData?.longitude
|
||||||
? { latitude: initialData.latitude, longitude: initialData.longitude }
|
? { latitude: initialData.latitude, longitude: initialData.longitude }
|
||||||
@@ -328,6 +334,130 @@ export function EventForm({ onSubmit, onCancel, initialData, isSubmitting = fals
|
|||||||
{errors.duration && <p className="mt-1 text-sm text-red-600">{errors.duration}</p>}
|
{errors.duration && <p className="mt-1 text-sm text-red-600">{errors.duration}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Equipment Section */}
|
||||||
|
<div className="border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">Equipment</h3>
|
||||||
|
|
||||||
|
{/* Equipment Provided Toggle */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="flex items-center cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.equipmentProvided}
|
||||||
|
onChange={(e) => {
|
||||||
|
setFormData(prev => ({ ...prev, equipmentProvided: e.target.checked }));
|
||||||
|
// Clear required items if equipment is provided
|
||||||
|
if (e.target.checked) {
|
||||||
|
setFormData(prev => ({ ...prev, requiredItems: [] }));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-5 h-5 text-blue-600 border-gray-300 rounded focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-3 text-sm font-medium text-gray-700">
|
||||||
|
Equipment will be provided by organizer
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-1 ml-8 text-sm text-gray-500">
|
||||||
|
Check this if you'll supply all necessary cleanup equipment
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required Items List (only shown if equipment not provided) */}
|
||||||
|
{!formData.equipmentProvided && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Items Participants Need to Bring
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 mb-3">
|
||||||
|
Add items that participants should bring (e.g., gloves, litter picker, bags, coat)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Add Item Input */}
|
||||||
|
<div className="flex gap-2 mb-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newItem}
|
||||||
|
onChange={(e) => setNewItem(e.target.value)}
|
||||||
|
onKeyPress={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
if (newItem.trim() && !formData.requiredItems.includes(newItem.trim())) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
requiredItems: [...prev.requiredItems, newItem.trim()]
|
||||||
|
}));
|
||||||
|
setNewItem('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="e.g., Gloves, Litter picker, Bags"
|
||||||
|
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
if (newItem.trim() && !formData.requiredItems.includes(newItem.trim())) {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
requiredItems: [...prev.requiredItems, newItem.trim()]
|
||||||
|
}));
|
||||||
|
setNewItem('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm font-medium"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Required Items List */}
|
||||||
|
{formData.requiredItems.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-gray-700">Required Items:</p>
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{formData.requiredItems.map((item, index) => (
|
||||||
|
<li
|
||||||
|
key={index}
|
||||||
|
className="flex items-center justify-between bg-white px-3 py-2 rounded-lg border border-gray-200"
|
||||||
|
>
|
||||||
|
<span className="text-sm text-gray-700">• {item}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
requiredItems: prev.requiredItems.filter((_, i) => i !== index)
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="text-red-600 hover:text-red-800 text-sm font-medium"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.requiredItems.length === 0 && (
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-700">
|
||||||
|
💡 Tip: Add common items like gloves, litter pickers, bags, or appropriate clothing
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formData.equipmentProvided && (
|
||||||
|
<div className="bg-green-50 border border-green-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
✓ Participants will be informed that all equipment will be provided
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Location Name */}
|
{/* Location Name */}
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="locationName" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="locationName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ export interface CleanupEvent {
|
|||||||
participantCount: number;
|
participantCount: number;
|
||||||
litterCollected?: number; // in kilograms
|
litterCollected?: number; // in kilograms
|
||||||
photos?: string[];
|
photos?: string[];
|
||||||
|
equipmentProvided?: boolean; // Whether equipment is provided by organizer
|
||||||
|
requiredItems?: string[]; // List of items participants need to bring
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user