This commit is contained in:
Jake Kasper
2025-08-21 17:27:56 -04:00
parent e5d8d2841d
commit 05011590e0
2 changed files with 383 additions and 6 deletions

View File

@@ -15,6 +15,7 @@
"leaflet": "^1.9.4",
"react-leaflet": "^4.2.1",
"leaflet-draw": "^1.0.4",
"@turf/turf": "^6.5.0",
"@headlessui/react": "^1.7.17",
"@heroicons/react": "^2.0.18",
"react-hook-form": "^7.48.2",

View File

@@ -1,16 +1,392 @@
import React from 'react';
import { useParams } from 'react-router-dom';
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { MapContainer, TileLayer, Marker, Popup, Polygon, useMapEvents } from 'react-leaflet';
import { Icon } from 'leaflet';
import * as turf from '@turf/turf';
import {
PlusIcon,
TrashIcon,
ArrowLeftIcon,
MapPinIcon,
SwatchIcon
} from '@heroicons/react/24/outline';
import { propertiesAPI } from '../../services/api';
import LoadingSpinner from '../../components/UI/LoadingSpinner';
import toast from 'react-hot-toast';
import 'leaflet/dist/leaflet.css';
// Fix for default markers
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',
});
// Colors for lawn sections
const SECTION_COLORS = [
{ name: 'Green', value: '#22c55e' },
{ name: 'Blue', value: '#3b82f6' },
{ name: 'Red', value: '#ef4444' },
{ name: 'Yellow', value: '#eab308' },
{ name: 'Purple', value: '#a855f7' },
{ name: 'Orange', value: '#f97316' },
];
// Calculate area in square feet
const calculateAreaInSqFt = (coordinates) => {
try {
const polygon = turf.polygon([coordinates.map(coord => [coord[1], coord[0]])]);
const areaInSqMeters = turf.area(polygon);
return Math.round(areaInSqMeters * 10.7639); // Convert to sq ft
} catch (error) {
console.error('Area calculation error:', error);
return 0;
}
};
// Component for drawing polygons
function PolygonDrawer({ isDrawing, onPolygonComplete, currentColor }) {
const [currentPolygon, setCurrentPolygon] = useState([]);
useMapEvents({
click(e) {
if (!isDrawing) return;
const newPoint = [e.latlng.lat, e.latlng.lng];
setCurrentPolygon(prev => [...prev, newPoint]);
},
dblclick() {
if (isDrawing && currentPolygon.length >= 3) {
onPolygonComplete(currentPolygon);
setCurrentPolygon([]);
}
}
});
return currentPolygon.length > 0 ? (
<Polygon
positions={currentPolygon}
pathOptions={{
color: currentColor.value,
fillColor: currentColor.value,
fillOpacity: 0.3,
weight: 2,
dashArray: '5, 5'
}}
/>
) : null;
}
const PropertyDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [property, setProperty] = useState(null);
const [lawnSections, setLawnSections] = useState([]);
const [loading, setLoading] = useState(true);
const [isDrawing, setIsDrawing] = useState(false);
const [currentColor, setCurrentColor] = useState(SECTION_COLORS[0]);
const [showNameModal, setShowNameModal] = useState(false);
const [pendingSection, setPendingSection] = useState(null);
const [sectionName, setSectionName] = useState('');
useEffect(() => {
fetchPropertyDetails();
}, [id]);
const fetchPropertyDetails = async () => {
try {
setLoading(true);
const response = await propertiesAPI.getById(id);
console.log('Property details:', response);
setProperty(response.data.data);
} catch (error) {
console.error('Failed to fetch property:', error);
toast.error('Failed to load property');
navigate('/properties');
} finally {
setLoading(false);
}
};
const handlePolygonComplete = (coordinates) => {
if (coordinates.length < 3) {
toast.error('Polygon needs at least 3 points');
return;
}
const area = calculateAreaInSqFt([...coordinates, coordinates[0]]);
setPendingSection({ coordinates, color: currentColor, area });
setShowNameModal(true);
setIsDrawing(false);
};
const saveLawnSection = () => {
if (!sectionName.trim()) {
toast.error('Please enter a section name');
return;
}
const newSection = {
id: Date.now(),
name: sectionName,
coordinates: pendingSection.coordinates,
color: pendingSection.color,
area: pendingSection.area
};
setLawnSections(prev => [...prev, newSection]);
toast.success(`${sectionName} section created!`);
// Reset and cycle color
setSectionName('');
setPendingSection(null);
setShowNameModal(false);
const nextIndex = (SECTION_COLORS.findIndex(c => c.value === currentColor.value) + 1) % SECTION_COLORS.length;
setCurrentColor(SECTION_COLORS[nextIndex]);
};
const deleteLawnSection = (sectionId) => {
if (window.confirm('Delete this lawn section?')) {
setLawnSections(prev => prev.filter(s => s.id !== sectionId));
toast.success('Section deleted');
}
};
const getTotalArea = () => {
return lawnSections.reduce((total, section) => total + section.area, 0);
};
if (loading) {
return (
<div className="p-6">
<div className="flex justify-center items-center h-64">
<LoadingSpinner size="lg" />
</div>
</div>
);
}
if (!property) {
return (
<div className="p-6">
<div className="card text-center py-12">
<h3 className="text-lg font-medium text-gray-900 mb-2">Property Not Found</h3>
<button onClick={() => navigate('/properties')} className="btn-primary">
Back to Properties
</button>
</div>
</div>
);
}
return (
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 mb-6">Property Details</h1>
<div className="card">
<p className="text-gray-600">Property {id} details coming soon...</p>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/properties')}
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ArrowLeftIcon className="h-5 w-5" />
</button>
<div>
<h1 className="text-2xl font-bold text-gray-900">{property.name}</h1>
<p className="text-gray-600 flex items-center gap-1">
<MapPinIcon className="h-4 w-4" />
{property.address}
</p>
</div>
</div>
<button
onClick={() => setIsDrawing(!isDrawing)}
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
>
<PlusIcon className="h-5 w-5" />
{isDrawing ? 'Cancel Drawing' : 'Add Lawn Section'}
</button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Map */}
<div className="lg:col-span-3">
<div className="card p-0 overflow-hidden">
<div style={{ height: '600px', width: '100%' }}>
<MapContainer
center={[property.latitude, property.longitude]}
zoom={19}
style={{ height: '100%', width: '100%' }}
>
<TileLayer
attribution='&copy; <a href="https://www.esri.com/">Esri</a>'
url="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}"
/>
<Marker position={[property.latitude, property.longitude]}>
<Popup>{property.name}</Popup>
</Marker>
{lawnSections.map((section) => (
<Polygon
key={section.id}
positions={section.coordinates}
pathOptions={{
color: section.color.value,
fillColor: section.color.value,
fillOpacity: 0.4,
weight: 2
}}
>
<Popup>
<div className="text-center">
<strong>{section.name}</strong><br />
{section.area.toLocaleString()} sq ft<br />
<button
onClick={() => deleteLawnSection(section.id)}
className="text-red-600 text-sm mt-2"
>
Delete
</button>
</div>
</Popup>
</Polygon>
))}
{isDrawing && (
<PolygonDrawer
isDrawing={isDrawing}
onPolygonComplete={handlePolygonComplete}
currentColor={currentColor}
/>
)}
</MapContainer>
</div>
{isDrawing && (
<div className="p-4 bg-blue-50">
<p className="text-sm text-blue-800">
<strong>Drawing Mode:</strong> Click to add points. Double-click to complete polygon.
</p>
</div>
)}
</div>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Color Selector */}
{isDrawing && (
<div className="card">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
<SwatchIcon className="h-5 w-5" />
Section Color
</h3>
<div className="grid grid-cols-3 gap-2">
{SECTION_COLORS.map((color) => (
<button
key={color.value}
onClick={() => setCurrentColor(color)}
className={`w-10 h-10 rounded border-2 ${
currentColor.value === color.value ? 'border-gray-900' : 'border-gray-300'
}`}
style={{ backgroundColor: color.value }}
title={color.name}
/>
))}
</div>
</div>
)}
{/* Property Summary */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Property Summary</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Total Sections:</span>
<span className="font-medium">{lawnSections.length}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Total Area:</span>
<span className="font-medium">{getTotalArea().toLocaleString()} sq ft</span>
</div>
</div>
</div>
{/* Lawn Sections */}
<div className="card">
<h3 className="text-lg font-semibold mb-4">Lawn Sections</h3>
{lawnSections.length === 0 ? (
<p className="text-gray-500 text-sm">No sections yet.</p>
) : (
<div className="space-y-2">
{lawnSections.map((section) => (
<div key={section.id} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<div className="flex items-center gap-2">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: section.color.value }}
/>
<div>
<p className="font-medium text-sm">{section.name}</p>
<p className="text-xs text-gray-600">{section.area.toLocaleString()} sq ft</p>
</div>
</div>
<button
onClick={() => deleteLawnSection(section.id)}
className="text-red-600 hover:text-red-800"
>
<TrashIcon className="h-4 w-4" />
</button>
</div>
))}
</div>
)}
</div>
</div>
</div>
{/* Name Modal */}
{showNameModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-96">
<h3 className="text-lg font-semibold mb-4">Name Your Lawn Section</h3>
<div className="space-y-4">
<input
type="text"
className="input"
value={sectionName}
onChange={(e) => setSectionName(e.target.value)}
placeholder="e.g., Front Yard, Back Lawn"
autoFocus
/>
<div className="flex items-center gap-2 text-sm">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: pendingSection?.color.value }}
/>
<span>{pendingSection?.area.toLocaleString()} sq ft</span>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={saveLawnSection} className="btn-primary flex-1">
Save
</button>
<button
onClick={() => {
setShowNameModal(false);
setPendingSection(null);
setSectionName('');
}}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</div>
</div>
)}
</div>
);
};