property enhancements
This commit is contained in:
@@ -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",
|
||||
|
||||
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 { 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 && (
|
||||
<div className="card mb-6">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">Add New Property</h3>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<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>
|
||||
<PropertyForm
|
||||
onSubmit={handleCreateProperty}
|
||||
onCancel={() => setShowCreateForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Properties List */}
|
||||
|
||||
Reference in New Issue
Block a user