property enhancements

This commit is contained in:
Jake Kasper
2025-08-21 14:08:17 -05:00
parent 6b36da7d75
commit 38dd0c69de
3 changed files with 313 additions and 93 deletions

View File

@@ -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",

View 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='&copy; <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;

View File

@@ -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 */}