diff --git a/frontend/package.json b/frontend/package.json index 3f6aacd..d3a96de 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "axios": "^1.6.2", "leaflet": "^1.9.4", "react-leaflet": "^4.2.1", + "leaflet-draw": "^1.0.4", "@headlessui/react": "^1.7.17", "@heroicons/react": "^2.0.18", "react-hook-form": "^7.48.2", diff --git a/frontend/src/components/Properties/PropertyForm.js b/frontend/src/components/Properties/PropertyForm.js new file mode 100644 index 0000000..55fe4bf --- /dev/null +++ b/frontend/src/components/Properties/PropertyForm.js @@ -0,0 +1,305 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { MapContainer, TileLayer, Marker, Popup, useMapEvents } from 'react-leaflet'; +import { Icon } from 'leaflet'; +import 'leaflet/dist/leaflet.css'; +import toast from 'react-hot-toast'; + +// Fix for default markers in react-leaflet +delete Icon.Default.prototype._getIconUrl; +Icon.Default.mergeOptions({ + iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png', + iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png', + shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png', +}); + +// Component to handle map clicks and update marker position +function LocationMarker({ position, setPosition }) { + const map = useMapEvents({ + click(e) { + setPosition([e.latlng.lat, e.latlng.lng]); + }, + }); + + return position === null ? null : ( + + + Property Location +
+ Drag the map or click to adjust the pin position +
+ Lat: {position[0].toFixed(6)}, Lng: {position[1].toFixed(6)} +
+
+ ); +} + +const PropertyForm = ({ onSubmit, onCancel, initialData = null }) => { + const [formData, setFormData] = useState({ + name: initialData?.name || '', + address: initialData?.address || '', + latitude: initialData?.latitude || '', + longitude: initialData?.longitude || '', + totalArea: initialData?.totalArea || '' + }); + + const [mapPosition, setMapPosition] = useState( + initialData?.latitude && initialData?.longitude + ? [initialData.latitude, initialData.longitude] + : [40.7128, -74.0060] // Default to NYC + ); + const [markerPosition, setMarkerPosition] = useState( + initialData?.latitude && initialData?.longitude + ? [initialData.latitude, initialData.longitude] + : null + ); + const [showMap, setShowMap] = useState(false); + const [addressSuggestions, setAddressSuggestions] = useState([]); + const [showSuggestions, setShowSuggestions] = useState(false); + const debounceRef = useRef(null); + + // Address autocomplete using Nominatim (OpenStreetMap) + const searchAddresses = async (query) => { + if (query.length < 3) { + setAddressSuggestions([]); + setShowSuggestions(false); + return; + } + + try { + const response = await fetch( + `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5&countrycodes=us` + ); + const data = await response.json(); + + const suggestions = data.map(item => ({ + display_name: item.display_name, + lat: parseFloat(item.lat), + lon: parseFloat(item.lon) + })); + + setAddressSuggestions(suggestions); + setShowSuggestions(true); + } catch (error) { + console.error('Address search failed:', error); + } + }; + + const handleAddressChange = (e) => { + const value = e.target.value; + setFormData({ ...formData, address: value }); + + // Debounce the search + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + searchAddresses(value); + }, 300); + }; + + const selectAddress = (suggestion) => { + setFormData({ + ...formData, + address: suggestion.display_name, + latitude: suggestion.lat, + longitude: suggestion.lon + }); + setMapPosition([suggestion.lat, suggestion.lon]); + setMarkerPosition([suggestion.lat, suggestion.lon]); + setShowSuggestions(false); + setShowMap(true); + }; + + const handleShowMap = () => { + if (formData.latitude && formData.longitude) { + const lat = parseFloat(formData.latitude); + const lng = parseFloat(formData.longitude); + setMapPosition([lat, lng]); + setMarkerPosition([lat, lng]); + } + setShowMap(true); + }; + + const updatePositionFromMarker = (position) => { + setMarkerPosition(position); + setFormData({ + ...formData, + latitude: position[0], + longitude: position[1] + }); + }; + + const handleSubmit = async (e) => { + e.preventDefault(); + + const propertyData = { + ...formData, + latitude: markerPosition ? markerPosition[0] : (formData.latitude ? parseFloat(formData.latitude) : null), + longitude: markerPosition ? markerPosition[1] : (formData.longitude ? parseFloat(formData.longitude) : null), + totalArea: formData.totalArea ? parseFloat(formData.totalArea) : null + }; + + try { + await onSubmit(propertyData); + } catch (error) { + console.error('Form submission failed:', error); + } + }; + + return ( +
+

+ {initialData ? 'Edit Property' : 'Add New Property'} +

+ +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="e.g., Main Lawn, Front Yard" + /> +
+ +
+ + setFormData({ ...formData, totalArea: e.target.value })} + placeholder="5000" + /> +
+
+ + {/* Address Autocomplete */} +
+ + setShowSuggestions(addressSuggestions.length > 0)} + placeholder="123 Main St, City, State" + /> + + {/* Address Suggestions */} + {showSuggestions && addressSuggestions.length > 0 && ( +
+ {addressSuggestions.map((suggestion, index) => ( + + ))} +
+ )} +
+ + {/* Manual Coordinates */} +
+
+ + setFormData({ ...formData, latitude: e.target.value })} + placeholder="40.7128" + /> +
+
+ + setFormData({ ...formData, longitude: e.target.value })} + placeholder="-74.0060" + /> +
+
+ + {/* Map Toggle */} +
+ + {!showMap && (formData.latitude && formData.longitude) && ( + + Click "Show Map" to adjust the exact location + + )} +
+ + {/* Interactive Map */} + {showMap && ( +
+
+
+ + + + +
+
+
+

Instructions:

+
    +
  • Click anywhere on the map to place or move the property marker
  • +
  • The marker represents the exact location of your property
  • +
  • Coordinates will be automatically updated when you move the marker
  • +
  • After setting the location, you can add lawn sections in the property details
  • +
+
+
+ )} + + {/* Form Actions */} +
+ + +
+
+
+ ); +}; + +export default PropertyForm; \ No newline at end of file diff --git a/frontend/src/pages/Properties/Properties.js b/frontend/src/pages/Properties/Properties.js index b03773a..3ef7a19 100644 --- a/frontend/src/pages/Properties/Properties.js +++ b/frontend/src/pages/Properties/Properties.js @@ -3,19 +3,13 @@ import { Link } from 'react-router-dom'; import { PlusIcon, MapPinIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline'; import { propertiesAPI } from '../../services/api'; import LoadingSpinner from '../../components/UI/LoadingSpinner'; +import PropertyForm from '../../components/Properties/PropertyForm'; import toast from 'react-hot-toast'; const Properties = () => { const [properties, setProperties] = useState([]); const [loading, setLoading] = useState(true); const [showCreateForm, setShowCreateForm] = useState(false); - const [formData, setFormData] = useState({ - name: '', - address: '', - latitude: '', - longitude: '', - totalArea: '' - }); useEffect(() => { fetchProperties(); @@ -45,24 +39,16 @@ const Properties = () => { } }; - const handleSubmit = async (e) => { - e.preventDefault(); + const handleCreateProperty = async (propertyData) => { try { - const propertyData = { - ...formData, - latitude: formData.latitude ? parseFloat(formData.latitude) : null, - longitude: formData.longitude ? parseFloat(formData.longitude) : null, - totalArea: formData.totalArea ? parseFloat(formData.totalArea) : null - }; - await propertiesAPI.create(propertyData); toast.success('Property created successfully!'); setShowCreateForm(false); - setFormData({ name: '', address: '', latitude: '', longitude: '', totalArea: '' }); fetchProperties(); } catch (error) { console.error('Failed to create property:', error); toast.error(error.response?.data?.message || 'Failed to create property'); + throw error; // Re-throw to let the form handle it } }; @@ -108,82 +94,10 @@ const Properties = () => { {/* Create Property Form */} {showCreateForm && ( -
-

Add New Property

-
-
-
- - setFormData({ ...formData, name: e.target.value })} - placeholder="e.g., Main Lawn, Front Yard" - /> -
-
- - setFormData({ ...formData, address: e.target.value })} - placeholder="123 Main St, City, State" - /> -
-
- - setFormData({ ...formData, latitude: e.target.value })} - placeholder="40.7128" - /> -
-
- - setFormData({ ...formData, longitude: e.target.value })} - placeholder="-74.0060" - /> -
-
- - setFormData({ ...formData, totalArea: e.target.value })} - placeholder="5000" - /> -
-
-
- - -
-
-
+ setShowCreateForm(false)} + /> )} {/* Properties List */}