property enhancements
This commit is contained in:
@@ -14,6 +14,7 @@
|
|||||||
"axios": "^1.6.2",
|
"axios": "^1.6.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
"react-leaflet": "^4.2.1",
|
"react-leaflet": "^4.2.1",
|
||||||
|
"leaflet-draw": "^1.0.4",
|
||||||
"@headlessui/react": "^1.7.17",
|
"@headlessui/react": "^1.7.17",
|
||||||
"@heroicons/react": "^2.0.18",
|
"@heroicons/react": "^2.0.18",
|
||||||
"react-hook-form": "^7.48.2",
|
"react-hook-form": "^7.48.2",
|
||||||
|
|||||||
305
frontend/src/components/Properties/PropertyForm.js
Normal file
305
frontend/src/components/Properties/PropertyForm.js
Normal file
@@ -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 : (
|
||||||
|
<Marker position={position}>
|
||||||
|
<Popup>
|
||||||
|
Property Location
|
||||||
|
<br />
|
||||||
|
Drag the map or click to adjust the pin position
|
||||||
|
<br />
|
||||||
|
Lat: {position[0].toFixed(6)}, Lng: {position[1].toFixed(6)}
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="card mb-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
{initialData ? 'Edit Property' : 'Add New Property'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Property Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
className="input"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="e.g., Main Lawn, Front Yard"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="label">Total Area (sq ft)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="input"
|
||||||
|
value={formData.totalArea}
|
||||||
|
onChange={(e) => setFormData({ ...formData, totalArea: e.target.value })}
|
||||||
|
placeholder="5000"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Address Autocomplete */}
|
||||||
|
<div className="relative">
|
||||||
|
<label className="label">Address</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input"
|
||||||
|
value={formData.address}
|
||||||
|
onChange={handleAddressChange}
|
||||||
|
onFocus={() => setShowSuggestions(addressSuggestions.length > 0)}
|
||||||
|
placeholder="123 Main St, City, State"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Address Suggestions */}
|
||||||
|
{showSuggestions && addressSuggestions.length > 0 && (
|
||||||
|
<div className="absolute z-10 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-auto">
|
||||||
|
{addressSuggestions.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
type="button"
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-gray-100 focus:bg-gray-100 focus:outline-none"
|
||||||
|
onClick={() => selectAddress(suggestion)}
|
||||||
|
>
|
||||||
|
{suggestion.display_name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Manual Coordinates */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="label">Latitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="input"
|
||||||
|
value={formData.latitude}
|
||||||
|
onChange={(e) => setFormData({ ...formData, latitude: e.target.value })}
|
||||||
|
placeholder="40.7128"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Longitude</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
className="input"
|
||||||
|
value={formData.longitude}
|
||||||
|
onChange={(e) => setFormData({ ...formData, longitude: e.target.value })}
|
||||||
|
placeholder="-74.0060"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Map Toggle */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleShowMap}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
{showMap ? 'Hide Map' : 'Show Map'}
|
||||||
|
</button>
|
||||||
|
{!showMap && (formData.latitude && formData.longitude) && (
|
||||||
|
<span className="text-sm text-gray-600 self-center">
|
||||||
|
Click "Show Map" to adjust the exact location
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Interactive Map */}
|
||||||
|
{showMap && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<div style={{ height: '400px', width: '100%' }}>
|
||||||
|
<MapContainer
|
||||||
|
center={mapPosition}
|
||||||
|
zoom={16}
|
||||||
|
style={{ height: '100%', width: '100%' }}
|
||||||
|
>
|
||||||
|
<TileLayer
|
||||||
|
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
|
/>
|
||||||
|
<LocationMarker
|
||||||
|
position={markerPosition}
|
||||||
|
setPosition={updatePositionFromMarker}
|
||||||
|
/>
|
||||||
|
</MapContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-600 bg-blue-50 p-3 rounded-lg">
|
||||||
|
<p><strong>Instructions:</strong></p>
|
||||||
|
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||||
|
<li>Click anywhere on the map to place or move the property marker</li>
|
||||||
|
<li>The marker represents the exact location of your property</li>
|
||||||
|
<li>Coordinates will be automatically updated when you move the marker</li>
|
||||||
|
<li>After setting the location, you can add lawn sections in the property details</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Form Actions */}
|
||||||
|
<div className="flex gap-3 pt-4 border-t border-gray-200">
|
||||||
|
<button type="submit" className="btn-primary">
|
||||||
|
{initialData ? 'Update Property' : 'Create Property'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onCancel}
|
||||||
|
className="btn-secondary"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PropertyForm;
|
||||||
@@ -3,19 +3,13 @@ import { Link } from 'react-router-dom';
|
|||||||
import { PlusIcon, MapPinIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
|
import { PlusIcon, MapPinIcon, TrashIcon, PencilIcon } from '@heroicons/react/24/outline';
|
||||||
import { propertiesAPI } from '../../services/api';
|
import { propertiesAPI } from '../../services/api';
|
||||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||||
|
import PropertyForm from '../../components/Properties/PropertyForm';
|
||||||
import toast from 'react-hot-toast';
|
import toast from 'react-hot-toast';
|
||||||
|
|
||||||
const Properties = () => {
|
const Properties = () => {
|
||||||
const [properties, setProperties] = useState([]);
|
const [properties, setProperties] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
name: '',
|
|
||||||
address: '',
|
|
||||||
latitude: '',
|
|
||||||
longitude: '',
|
|
||||||
totalArea: ''
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProperties();
|
fetchProperties();
|
||||||
@@ -45,24 +39,16 @@ const Properties = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e) => {
|
const handleCreateProperty = async (propertyData) => {
|
||||||
e.preventDefault();
|
|
||||||
try {
|
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);
|
await propertiesAPI.create(propertyData);
|
||||||
toast.success('Property created successfully!');
|
toast.success('Property created successfully!');
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
setFormData({ name: '', address: '', latitude: '', longitude: '', totalArea: '' });
|
|
||||||
fetchProperties();
|
fetchProperties();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to create property:', error);
|
console.error('Failed to create property:', error);
|
||||||
toast.error(error.response?.data?.message || 'Failed to create property');
|
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 */}
|
{/* Create Property Form */}
|
||||||
{showCreateForm && (
|
{showCreateForm && (
|
||||||
<div className="card mb-6">
|
<PropertyForm
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Add New Property</h3>
|
onSubmit={handleCreateProperty}
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
onCancel={() => setShowCreateForm(false)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
/>
|
||||||
<div>
|
|
||||||
<label className="label">Property Name *</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
required
|
|
||||||
className="input"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="e.g., Main Lawn, Front Yard"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Address</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="input"
|
|
||||||
value={formData.address}
|
|
||||||
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
|
||||||
placeholder="123 Main St, City, State"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Latitude</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
className="input"
|
|
||||||
value={formData.latitude}
|
|
||||||
onChange={(e) => setFormData({ ...formData, latitude: e.target.value })}
|
|
||||||
placeholder="40.7128"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Longitude</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
className="input"
|
|
||||||
value={formData.longitude}
|
|
||||||
onChange={(e) => setFormData({ ...formData, longitude: e.target.value })}
|
|
||||||
placeholder="-74.0060"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="label">Total Area (sq ft)</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
className="input"
|
|
||||||
value={formData.totalArea}
|
|
||||||
onChange={(e) => setFormData({ ...formData, totalArea: e.target.value })}
|
|
||||||
placeholder="5000"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<button type="submit" className="btn-primary">
|
|
||||||
Create Property
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setShowCreateForm(false);
|
|
||||||
setFormData({ name: '', address: '', latitude: '', longitude: '', totalArea: '' });
|
|
||||||
}}
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Properties List */}
|
{/* Properties List */}
|
||||||
|
|||||||
Reference in New Issue
Block a user