Files
turftracker/frontend/src/pages/Properties/PropertyDetail.js
2025-09-05 12:39:15 -04:00

1496 lines
64 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { MapContainer, TileLayer, Marker, Popup, Polygon, Polyline, useMapEvents, useMap } from 'react-leaflet';
import { Icon } from 'leaflet';
import * as turf from '@turf/turf';
import {
PlusIcon,
TrashIcon,
ArrowLeftIcon,
MapPinIcon,
SwatchIcon,
PencilIcon
} from '@heroicons/react/24/outline';
import { propertiesAPI, applicationsAPI, mowingAPI } from '../../services/api';
import ApplicationViewModal from '../../components/Applications/ApplicationViewModal';
import MowingSessionViewModal from '../../components/Mowing/MowingSessionViewModal';
import { EyeIcon, CalendarIcon, WrenchScrewdriverIcon } from '@heroicons/react/24/outline';
import LoadingSpinner from '../../components/UI/LoadingSpinner';
import toast from 'react-hot-toast';
import 'leaflet/dist/leaflet.css';
// Simple tag input and suggestion components for grass types
const COOL_SEASON_GRASSES = [
'Turf Type Tall Fescue',
'Kentucky Bluegrass',
'Perennial Ryegrass',
'Fine Fescue',
'Creeping Red Fescue',
'Chewings Fescue',
'Hard Fescue',
'Annual Ryegrass'
];
const TagInput = ({ value = [], onChange }) => {
const [input, setInput] = useState('');
const add = (val) => { const v = val.trim(); if (!v) return; if (!value.includes(v)) onChange([...(value||[]), v]); setInput(''); };
return (
<div className="border rounded p-2">
<div className="flex flex-wrap gap-2 mb-2">
{(value||[]).map((t) => (
<span key={t} className="px-2 py-1 bg-gray-100 rounded text-xs flex items-center gap-1">
{t}
<button className="text-gray-500 hover:text-gray-700" onClick={() => onChange((value||[]).filter(x=>x!==t))}>×</button>
</span>
))}
</div>
<input
className="w-full border-0 focus:outline-none text-sm"
placeholder="Type and press Enter to add"
value={input}
onChange={(e)=> setInput(e.target.value)}
onKeyDown={(e)=> { if (e.key==='Enter'){ e.preventDefault(); add(input); } }}
/>
</div>
);
};
const SuggestionChips = ({ onPick }) => (
<div className="flex flex-wrap gap-2 mt-2">
{COOL_SEASON_GRASSES.map(g => (
<button key={g} type="button" onClick={()=> onPick(g)} className="px-2 py-1 bg-blue-50 hover:bg-blue-100 text-blue-700 rounded text-xs">
{g}
</button>
))}
</div>
);
// 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;
}
};
// Calculate distance from a point to a line segment
const pointToLineDistance = (point, lineStart, lineEnd) => {
const [px, py] = point;
const [x1, y1] = lineStart;
const [x2, y2] = lineEnd;
const A = px - x1;
const B = py - y1;
const C = x2 - x1;
const D = y2 - y1;
const dot = A * C + B * D;
const lenSq = C * C + D * D;
if (lenSq === 0) return Math.sqrt(A * A + B * B);
let param = dot / lenSq;
let xx, yy;
if (param < 0) {
xx = x1;
yy = y1;
} else if (param > 1) {
xx = x2;
yy = y2;
} else {
xx = x1 + param * C;
yy = y1 + param * D;
}
const dx = px - xx;
const dy = py - yy;
return Math.sqrt(dx * dx + dy * dy);
};
// 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];
console.log('Adding point:', newPoint);
setCurrentPolygon(prev => {
const newPoly = [...prev, newPoint];
console.log('Current polygon has', newPoly.length, 'points');
return newPoly;
});
},
dblclick(e) {
console.log('Double click detected, drawing:', isDrawing, 'points:', currentPolygon.length);
if (isDrawing && currentPolygon.length >= 3) {
e.originalEvent.preventDefault();
e.originalEvent.stopPropagation();
console.log('Completing polygon with', currentPolygon.length, 'points');
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;
}
// Component for editable polygons
function EditablePolygon({ section, onUpdate, onEdit, onDelete }) {
// Import toast function from the module scope
const [isEditing, setIsEditing] = useState(false);
const [editedCoordinates, setEditedCoordinates] = useState(section.coordinates);
const map = useMap();
const handleMarkerDrag = (index, newLatLng) => {
const newCoords = [...editedCoordinates];
newCoords[index] = [newLatLng.lat, newLatLng.lng];
setEditedCoordinates(newCoords);
// Recalculate area
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
};
const addPointAtIndex = (index, e) => {
e.originalEvent.stopPropagation();
const newCoords = [...editedCoordinates];
const newPoint = [e.latlng.lat, e.latlng.lng];
newCoords.splice(index + 1, 0, newPoint);
setEditedCoordinates(newCoords);
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
};
const removePoint = (index) => {
if (editedCoordinates.length <= 3) {
toast.error('Polygon must have at least 3 points');
return;
}
const newCoords = editedCoordinates.filter((_, i) => i !== index);
setEditedCoordinates(newCoords);
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
};
return (
<>
<Polygon
positions={editedCoordinates}
pathOptions={{
color: section.color.value,
fillColor: section.color.value,
fillOpacity: isEditing ? 0.3 : 0.4,
weight: isEditing ? 3 : 2
}}
eventHandlers={{
click: (e) => {
if (!isEditing) {
setIsEditing(true);
toast('Edit mode enabled: Drag points to move, right-click to remove points, click edges to add points');
} else {
// Add point when clicking on polygon edge during edit mode
const newPoint = [e.latlng.lat, e.latlng.lng];
// Find the closest edge and insert the point there
let closestEdgeIndex = 0;
let minDistance = Infinity;
for (let i = 0; i < editedCoordinates.length; i++) {
const start = editedCoordinates[i];
const end = editedCoordinates[(i + 1) % editedCoordinates.length];
// Calculate distance from clicked point to edge
const distance = pointToLineDistance(newPoint, start, end);
if (distance < minDistance) {
minDistance = distance;
closestEdgeIndex = i;
}
}
// Insert new point after the closest edge start point
const newCoords = [...editedCoordinates];
newCoords.splice(closestEdgeIndex + 1, 0, newPoint);
setEditedCoordinates(newCoords);
const area = calculateAreaInSqFt([...newCoords, newCoords[0]]);
onUpdate(section.id, { ...section, coordinates: newCoords, area });
toast('Point added! Drag it to adjust position');
}
}
}}
>
<Popup>
<div className="text-center">
<strong>{section.name}</strong><br />
{section.area.toLocaleString()} sq ft<br />
<div className="flex gap-2 mt-2">
<button
onClick={() => {
setIsEditing(!isEditing);
toast(isEditing ? 'Edit mode disabled' : 'Edit mode enabled');
}}
className={`text-sm ${isEditing ? 'text-green-600' : 'text-blue-600'}`}
title={isEditing ? 'Exit edit mode' : 'Drag corners, click edges to add points, right-click to remove'}
>
{isEditing ? 'Done' : 'Edit Points'}
</button>
<button
onClick={() => onEdit(section)}
className="text-blue-600 text-sm"
>
Edit Name
</button>
<button
onClick={() => onDelete(section.id)}
className="text-red-600 text-sm"
>
Delete
</button>
</div>
</div>
</Popup>
</Polygon>
{/* Editable markers for each point */}
{isEditing && editedCoordinates.map((coord, index) => (
<Marker
key={`${section.id}-${index}`}
position={coord}
draggable={true}
eventHandlers={{
dragend: (e) => {
handleMarkerDrag(index, e.target.getLatLng());
},
contextmenu: (e) => {
e.originalEvent.preventDefault();
if (editedCoordinates.length > 3) {
removePoint(index);
}
}
}}
icon={new Icon({
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
<svg width="12" height="12" viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<circle cx="6" cy="6" r="5" fill="${section.color.value}" stroke="white" stroke-width="2"/>
</svg>
`),
iconSize: [12, 12],
iconAnchor: [6, 6]
})}
>
<Popup>
<div className="text-center">
<p className="text-xs">Point {index + 1}</p>
<button
onClick={() => removePoint(index)}
className="text-red-600 text-xs"
disabled={editedCoordinates.length <= 3}
>
Remove Point
</button>
</div>
</Popup>
</Marker>
))}
</>
);
}
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);
// GPS capture modes
const [isGPSPointsMode, setIsGPSPointsMode] = useState(false); // mark-at-location
const [isGPSTraceMode, setIsGPSTraceMode] = useState(false); // continuous trace
const [isTracing, setIsTracing] = useState(false);
const [gpsWatchId, setGpsWatchId] = useState(null);
const [gpsTracePoints, setGpsTracePoints] = useState([]);
const [gpsSamples, setGpsSamples] = useState([]); // recent raw samples for smoothing
const [gpsDistance, setGpsDistance] = useState(0);
const [gpsAccuracy, setGpsAccuracy] = useState(null);
const [isSnapPreview, setIsSnapPreview] = useState(false);
const [showAddMenu, setShowAddMenu] = useState(false);
// Modal picker to ensure options show reliably on mobile
const [showCaptureModeModal, setShowCaptureModeModal] = useState(false);
const [currentColor, setCurrentColor] = useState(SECTION_COLORS[0]);
const [showNameModal, setShowNameModal] = useState(false);
const [pendingSection, setPendingSection] = useState(null);
const [sectionName, setSectionName] = useState('');
const [sectionGrassTypes, setSectionGrassTypes] = useState([]);
const [editingSection, setEditingSection] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const [editGrassTypes, setEditGrassTypes] = useState([]);
// Recent history state for this property
const [completedApplications, setCompletedApplications] = useState([]);
const [applicationLogs, setApplicationLogs] = useState([]);
const [mowingLogs, setMowingLogs] = useState([]);
const [historyLoading, setHistoryLoading] = useState(false);
const [viewingApplication, setViewingApplication] = useState(null);
const [viewingMowingSession, setViewingMowingSession] = useState(null);
useEffect(() => {
fetchPropertyDetails();
}, [id]); // eslint-disable-line react-hooks/exhaustive-deps
// Load recent history when property is available
useEffect(() => {
if (!property?.id) return;
const loadHistory = async () => {
try {
setHistoryLoading(true);
const [completedRes, archivedRes, logsRes, mowRes] = await Promise.all([
applicationsAPI.getPlans({ status: 'completed', property_id: property.id }),
applicationsAPI.getPlans({ status: 'archived', property_id: property.id }),
applicationsAPI.getLogs({ property_id: property.id }),
mowingAPI.getLogs({ property_id: property.id })
]);
const completedPlans = completedRes.data?.data?.plans || [];
const archivedPlans = archivedRes.data?.data?.plans || [];
setCompletedApplications([...(completedPlans||[]), ...(archivedPlans||[])]);
setApplicationLogs(logsRes.data?.data?.logs || []);
setMowingLogs(mowRes.data?.data?.logs || []);
} catch (e) {
console.warn('Failed to load property history', e?.response?.data || e.message);
setCompletedApplications([]);
setApplicationLogs([]);
setMowingLogs([]);
} finally { setHistoryLoading(false); }
};
loadHistory();
}, [property?.id]);
// Handle keyboard shortcuts for polygon drawing
useEffect(() => {
const handleKeyPress = (e) => {
if (isDrawing && e.key === 'Enter') {
// Find the PolygonDrawer component and complete the polygon
console.log('Enter pressed during drawing mode');
}
if (e.key === 'Escape' && isDrawing) {
setIsDrawing(false);
toast('Drawing cancelled');
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, [isDrawing]);
const fetchPropertyDetails = async () => {
try {
setLoading(true);
const response = await propertiesAPI.getById(id);
console.log('Property details:', response);
const propertyData = response.data.data.property;
setProperty(propertyData);
// Convert backend sections to frontend format
if (propertyData.sections && propertyData.sections.length > 0) {
const convertedSections = propertyData.sections.map(section => {
const polyRaw = typeof section.polygonData === 'string' ? JSON.parse(section.polygonData) : section.polygonData;
const ring = Array.isArray(polyRaw?.coordinates?.[0]) ? polyRaw.coordinates[0] : [];
const coords = ring.map(pt => [Number(pt[0]), Number(pt[1])]);
const colorRaw = polyRaw?.color;
const color = (colorRaw && typeof colorRaw === 'object' && 'value' in colorRaw)
? colorRaw
: (typeof colorRaw === 'string' ? { name: 'Custom', value: colorRaw } : SECTION_COLORS[0]);
return {
id: section.id,
name: section.name,
coordinates: coords,
color,
area: section.area,
grassType: section.grassType || '',
grassTypes: section.grassTypes || null
};
});
setLawnSections(convertedSections);
}
} catch (error) {
console.error('Failed to fetch property:', error);
toast.error('Failed to load property');
navigate('/properties');
} finally {
setLoading(false);
}
};
const handlePolygonComplete = (coordinates) => {
console.log('handlePolygonComplete called with', coordinates.length, 'coordinates');
if (coordinates.length < 3) {
toast.error('Polygon needs at least 3 points');
return;
}
const area = calculateAreaInSqFt([...coordinates, coordinates[0]]);
console.log('Calculated area:', area);
setPendingSection({ coordinates, color: currentColor, area });
setShowNameModal(true);
setIsDrawing(false);
};
// Haversine distance in meters
const haversine = (lat1, lon1, lat2, lon2) => {
const toRad = (d) => (d * Math.PI) / 180;
const R = 6371000;
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a = Math.sin(dLat/2)**2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon/2)**2;
return 2 * R * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
// GPS filtering and snapping (defaults)
const ACCURACY_MAX_METERS = 12; // Ignore points with accuracy worse than this
const MIN_MOVE_METERS = 2.5; // Ignore points if user hasn't moved this far from last point
const TURN_MIN_DEG = 12; // Minimum heading change to record when small movement
const SNAP_METERS = 5; // Snap to starting point when within this distance
// Compute bearing between two lat/lng points (degrees)
const bearingDeg = (a, b) => {
const toRad = (d) => (d * Math.PI) / 180;
const toDeg = (r) => (r * 180) / Math.PI;
const lat1 = toRad(a[0]);
const lat2 = toRad(b[0]);
const dLng = toRad(b[1] - a[1]);
const y = Math.sin(dLng) * Math.cos(lat2);
const x = Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
const brg = (toDeg(Math.atan2(y, x)) + 360) % 360;
return brg;
};
// Maintain a short buffer of samples; return averaged lat/lng of best by accuracy
const addSampleAndSmooth = (lat, lng, accuracy) => {
const now = Date.now();
setGpsSamples((prev) => [...prev, { lat, lng, accuracy: accuracy ?? 999, t: now }].slice(-12));
const pool = [...gpsSamples, { lat, lng, accuracy: accuracy ?? 999, t: now }].slice(-12);
const top = pool
.filter((s) => s.accuracy != null)
.sort((a, b) => a.accuracy - b.accuracy)
.slice(0, Math.min(5, pool.length));
if (top.length === 0) return [lat, lng];
const avgLat = top.reduce((s, p) => s + p.lat, 0) / top.length;
const avgLng = top.reduce((s, p) => s + p.lng, 0) / top.length;
return [avgLat, avgLng];
};
// Get the most accurate recent sample (prefer samples within 3s)
const bestRecentSample = () => {
const now = Date.now();
const recent = gpsSamples.filter(s => (now - (s.t || 0)) <= 3000);
const pool = (recent.length ? recent : gpsSamples).slice(-12);
if (pool.length === 0) return null;
const best = pool.reduce((b, s) => (s.accuracy < (b?.accuracy ?? Infinity) ? s : b), null);
return best ? [best.lat, best.lng, best.accuracy] : null;
};
const acceptAndNormalizePoint = (lat, lng, accuracy, currentPoints) => {
if (accuracy != null && accuracy > ACCURACY_MAX_METERS) {
toast("GPS accuracy too low (" + Math.round(accuracy) + "m). Waiting for better fix…");
return null;
}
// Movement threshold
if (currentPoints.length > 0) {
const [llat, llng] = currentPoints[currentPoints.length - 1];
const moved = haversine(llat, llng, lat, lng);
if (moved < MIN_MOVE_METERS) {
// Allow small move only if heading changed sufficiently vs previous segment
if (currentPoints.length >= 2) {
const prevHead = bearingDeg(currentPoints[currentPoints.length - 2], [llat, llng]);
const candHead = bearingDeg([llat, llng], [lat, lng]);
let diff = Math.abs(prevHead - candHead);
if (diff > 180) diff = 360 - diff;
if (diff < TURN_MIN_DEG) return null;
} else {
return null;
}
}
}
// Snap to starting point if near
if (currentPoints.length >= 2) {
const [slat, slng] = currentPoints[0];
const dStart = haversine(slat, slng, lat, lng);
if (dStart <= SNAP_METERS) {
return [slat, slng];
}
}
return [lat, lng];
};
// GPS point collection (mark-at-location)
const markCurrentPoint = () => {
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
// Prefer most accurate recent fix from warm watch; fall back to immediate read
const candidate = bestRecentSample();
const applyFix = (lat, lng, acc) => {
setGpsAccuracy(acc || null);
setGpsTracePoints(prev => {
const normalized = acceptAndNormalizePoint(lat, lng, acc, prev);
if (prev.length >= 2) {
const [slat, slng] = prev[0];
const dStart = haversine(slat, slng, lat, lng);
setIsSnapPreview(dStart <= SNAP_METERS);
} else { setIsSnapPreview(false); }
if (!normalized) return prev; // skip
const next = [...prev, normalized];
if (next.length > 1) {
const [pl, pg] = next[next.length - 2];
setGpsDistance(d => d + haversine(pl, pg, normalized[0], normalized[1]));
}
return next;
});
};
if (candidate) {
const [lat, lng, acc] = candidate;
applyFix(lat, lng, acc);
} else {
navigator.geolocation.getCurrentPosition((pos) => {
const { latitude, longitude, accuracy } = pos.coords;
addSampleAndSmooth(latitude, longitude, accuracy);
applyFix(latitude, longitude, accuracy);
}, (err)=>{
console.warn('GPS error', err?.message);
toast.error('GPS error: ' + (err?.message || 'unknown'));
}, { enableHighAccuracy: true, maximumAge: 500, timeout: 8000 });
}
};
const undoLastPoint = () => {
setGpsTracePoints(prev => {
if (prev.length <= 0) return prev;
const next = prev.slice(0, -1);
// Recompute distance
let dist = 0;
for (let i=1;i<next.length;i++) dist += haversine(next[i-1][0], next[i-1][1], next[i][0], next[i][1]);
setGpsDistance(dist);
return next;
});
};
const clearGpsPoints = () => {
setGpsTracePoints([]);
setGpsDistance(0);
setGpsAccuracy(null);
// Stop passive watch if running (used for Points mode warmup)
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
};
const completeTracing = () => {
if (gpsTracePoints.length < 3) { toast.error('Need at least 3 points to create an area'); return; }
// Close polygon by ensuring first == last handled in save
handlePolygonComplete(gpsTracePoints);
setIsGPSTraceMode(false);
setIsGPSPointsMode(false);
clearGpsPoints();
};
const saveLawnSection = async () => {
if (!sectionName.trim()) {
toast.error('Please enter a section name');
return;
}
try {
// Ensure closed ring and recompute area
let coords = pendingSection.coordinates || [];
if (coords.length >= 3) {
const [f0, f1] = coords[0] || [];
const [l0, l1] = coords[coords.length - 1] || [];
if (f0 !== l0 || f1 !== l1) coords = [...coords, coords[0]];
}
if (coords.length < 4) { toast.error('Polygon invalid: need at least 3 unique points'); return; }
const recomputedArea = calculateAreaInSqFt(coords);
if (!recomputedArea || recomputedArea <= 0) { toast.error('Polygon area is zero — adjust points'); return; }
const sectionData = {
name: sectionName,
area: recomputedArea,
polygonData: {
coordinates: [coords],
color: pendingSection.color
},
grassType: sectionGrassTypes.join(', '),
grassTypes: sectionGrassTypes,
soilType: null,
captureMethod: isGPSPointsMode ? 'gps_points' : (isGPSTraceMode ? 'gps_trace' : 'tap'),
captureMeta: {
accuracyLast: gpsAccuracy,
totalDistanceMeters: gpsDistance,
pointsCount: pendingSection.coordinates?.length || 0
}
};
const response = await propertiesAPI.createSection(id, sectionData);
const savedSection = response.data.data.section;
// Convert backend format to frontend format; parse polygonData if string
const poly = typeof savedSection.polygonData === 'string' ? JSON.parse(savedSection.polygonData) : savedSection.polygonData;
const ring = Array.isArray(poly?.coordinates?.[0]) ? poly.coordinates[0] : [];
const coordsNorm = ring.map(pt => [Number(pt[0]), Number(pt[1])]);
const colorRaw = poly?.color;
const colorNorm = (colorRaw && typeof colorRaw === 'object' && 'value' in colorRaw)
? colorRaw
: (typeof colorRaw === 'string' ? { name: 'Custom', value: colorRaw } : currentColor);
const newSection = {
id: savedSection.id,
name: savedSection.name,
coordinates: coordsNorm,
color: colorNorm,
area: savedSection.area,
grassType: savedSection.grassType || '',
grassTypes: savedSection.grassTypes || []
};
setLawnSections(prev => [...prev, newSection]);
toast.success(`${sectionName} section created and saved!`);
// Reset and cycle color
setSectionName('');
setSectionGrassTypes([]);
setPendingSection(null);
setShowNameModal(false);
const nextIndex = (SECTION_COLORS.findIndex(c => c.value === currentColor.value) + 1) % SECTION_COLORS.length;
setCurrentColor(SECTION_COLORS[nextIndex]);
} catch (error) {
console.error('Failed to save section:', error);
toast.error(error?.response?.data?.message || error?.message || 'Failed to save section. Please try again.');
}
};
const deleteLawnSection = async (sectionId) => {
if (window.confirm('Delete this lawn section?')) {
try {
await propertiesAPI.deleteSection(id, sectionId);
setLawnSections(prev => prev.filter(s => s.id !== sectionId));
toast.success('Section deleted');
} catch (error) {
console.error('Failed to delete section:', error);
toast.error('Failed to delete section. Please try again.');
}
}
};
const startEditSection = (section) => {
setEditingSection(section);
setSectionName(section.name);
setCurrentColor(section.color);
setShowEditModal(true);
const existing = (section.grassType || '').split(',').map(s=>s.trim()).filter(Boolean);
setEditGrassTypes(existing);
};
const saveEditedSection = async () => {
if (!sectionName.trim()) {
toast.error('Please enter a section name');
return;
}
try {
const sectionData = {
name: sectionName,
area: editingSection.area,
polygonData: {
coordinates: [editingSection.coordinates],
color: currentColor
},
grassType: editGrassTypes.join(', '),
grassTypes: editGrassTypes,
soilType: null
};
await propertiesAPI.updateSection(id, editingSection.id, sectionData);
const updatedSection = {
...editingSection,
name: sectionName,
color: currentColor,
grassType: editGrassTypes.join(', '),
grassTypes: [...editGrassTypes]
};
setLawnSections(prev => prev.map(s => s.id === editingSection.id ? updatedSection : s));
toast.success('Section updated and saved!');
// Reset
setSectionName('');
setEditingSection(null);
setShowEditModal(false);
setEditGrassTypes([]);
} catch (error) {
console.error('Failed to update section:', error);
toast.error('Failed to update section. Please try again.');
}
};
const updateSection = async (sectionId, updatedSection) => {
try {
const sectionData = {
name: updatedSection.name,
area: updatedSection.area,
polygonData: {
coordinates: [updatedSection.coordinates],
color: updatedSection.color
},
grassType: (updatedSection.grassType || ''),
grassTypes: (updatedSection.grassTypes || null),
soilType: null
};
await propertiesAPI.updateSection(id, sectionId, sectionData);
setLawnSections(prev => prev.map(s => s.id === sectionId ? updatedSection : s));
} catch (error) {
console.error('Failed to update section coordinates:', error);
toast.error('Failed to save polygon changes. Please try again.');
}
};
const getTotalArea = () => {
return lawnSections.reduce((total, section) => total + section.area, 0);
};
// History helpers
const getApplicationType = (app) => {
if (!app.productDetails || app.productDetails.length === 0) return null;
return app.productDetails[0].type;
};
const calculateCoverage = (application, log) => {
if (!log?.gpsTrack?.points && !log?.gps_track?.points) return 0;
const storedMeters = typeof (log.gpsTrack?.totalDistance || log.gps_track?.totalDistance) === 'number' ? (log.gpsTrack?.totalDistance || log.gps_track?.totalDistance) : 0;
const totalDistanceFeet = storedMeters * 3.28084;
const plannedArea = application.totalSectionArea || application.total_section_area || 0;
if (totalDistanceFeet === 0 || plannedArea === 0) return 0;
let equipmentWidth = 4;
const equipmentName = (application.equipmentName || '').toLowerCase();
if (equipmentName.includes('spreader')) equipmentWidth = 12;
else if (equipmentName.includes('sprayer')) equipmentWidth = 20;
else if (equipmentName.includes('mower')) equipmentWidth = 6;
const theoreticalCoverageArea = totalDistanceFeet * equipmentWidth;
return Math.min(Math.round((theoreticalCoverageArea / plannedArea) * 100), 100);
};
const calculateMowingCoverage = (log) => {
const meters = (log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0) || 0;
const totalDistanceFeet = meters * 3.28084;
const plannedArea = Number(log.total_area || 0);
const widthInches = parseFloat(log.cutting_width_inches || 0);
const widthFeet = isNaN(widthInches) ? 0 : (widthInches / 12);
if (totalDistanceFeet === 0 || plannedArea === 0 || widthFeet === 0) return 0;
const theoreticalCoverageArea = totalDistanceFeet * widthFeet;
return Math.min(100, Math.round((theoreticalCoverageArea / plannedArea) * 100));
};
const unifiedHistory = (() => {
const apps = (completedApplications||[]).map((application) => {
const log = applicationLogs.find((l) => l.planId === application.id);
const dateStr = log?.applicationDate || application.plannedDate || application.updatedAt || application.createdAt;
const date = dateStr ? new Date(dateStr) : new Date(0);
return { kind: 'application', date, application, log };
});
const mows = (mowingLogs||[]).map((log) => {
const dateStr = log.session_date || log.created_at;
const date = dateStr ? new Date(dateStr) : new Date(0);
return { kind: 'mowing', date, log };
});
const items = [...apps, ...mows];
items.sort((a,b)=> b.date - a.date);
return items.slice(0, 10);
})();
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>
);
}
// Validate coordinates and provide fallbacks
const hasValidCoordinates = property &&
property.latitude !== undefined &&
property.longitude !== undefined &&
property.latitude !== null &&
property.longitude !== null &&
!isNaN(property.latitude) &&
!isNaN(property.longitude);
const mapCenter = hasValidCoordinates
? [property.latitude, property.longitude]
: [40.7128, -74.0060]; // Default to NYC
return (
<div className="p-6">
{/* 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 || 'No address specified'}
</p>
{!hasValidCoordinates && (
<p className="text-red-600 text-sm"> Property coordinates not set - using default location</p>
)}
</div>
</div>
<div className="relative">
<button
onClick={() => setShowCaptureModeModal(true)}
className="btn-primary flex items-center gap-2"
title="Add a lawn section"
>
<PlusIcon className="h-5 w-5" />
Add Lawn Section
</button>
{/* Dropdown removed in favor of modal for reliability */}
</div>
</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={mapCenter}
zoom={hasValidCoordinates ? 18 : 13}
maxZoom={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}"
maxZoom={19}
/>
{hasValidCoordinates && (
<Marker position={mapCenter}>
<Popup>{property.name}</Popup>
</Marker>
)}
{lawnSections.map((section) => (
<EditablePolygon
key={section.id}
section={section}
onUpdate={updateSection}
onEdit={startEditSection}
onDelete={deleteLawnSection}
/>
))}
{isDrawing && (
<PolygonDrawer
isDrawing={isDrawing}
onPolygonComplete={handlePolygonComplete}
currentColor={currentColor}
/>
)}
{/* GPS trace overlays */}
{(isGPSPointsMode || isGPSTraceMode) && gpsTracePoints.length > 0 && (
<>
<Polyline positions={gpsTracePoints} pathOptions={{ color: '#2563eb', weight: 3 }} />
{gpsTracePoints.map((p, i) => (
<Marker
key={`g${i}`}
position={p}
icon={new Icon({
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
<svg width="14" height="14" viewBox="0 0 14 14" xmlns="http://www.w3.org/2000/svg">
${i===0 && isSnapPreview ? '<circle cx="7" cy="7" r="6" fill="#fde68a" stroke="#f59e0b" stroke-width="2"/>' : '<circle cx="7" cy="7" r="6" fill="#2563eb" stroke="white" stroke-width="2"/>'}
</svg>
`),
iconSize: [14,14], iconAnchor: [7,7]
})}
/>
))}
</>
)}
</MapContainer>
</div>
{isDrawing && (
<div className="p-4 bg-blue-50 border-t">
<div className="flex items-center gap-2">
<div className="w-3 h-3 bg-blue-600 rounded-full animate-pulse"></div>
<p className="text-sm text-blue-800">
<strong>Drawing Mode Active:</strong> Click to add points. Double-click to complete polygon.
</p>
</div>
<p className="text-xs text-blue-600 mt-1">
Need at least 3 points to create a section. Press ESC to cancel.
</p>
<p className="text-xs text-blue-500 mt-1">
💡 After creating: Click any polygon to edit its points by dragging
</p>
</div>
)}
{isGPSPointsMode && (
<div className="p-4 bg-green-50 border-t">
<div className="flex flex-wrap items-center gap-3 text-sm text-green-900">
<span className="font-semibold">GPS Points Mode</span>
<span>Points: {gpsTracePoints.length}</span>
<span>Distance: {(gpsDistance * 3.28084).toFixed(0)} ft</span>
{gpsAccuracy != null && <span>Accuracy: ±{Math.round(gpsAccuracy)} m</span>}
</div>
<div className="mt-3 flex gap-2 items-center">
<button className="btn-primary" onClick={markCurrentPoint}>Mark Point</button>
<button className="btn-secondary" onClick={undoLastPoint} disabled={gpsTracePoints.length===0}>Undo</button>
<button className="btn-secondary" onClick={clearGpsPoints} disabled={gpsTracePoints.length===0}>Clear</button>
<button className="btn-primary" onClick={completeTracing} disabled={gpsTracePoints.length < 3}>Complete Boundary</button>
{isSnapPreview && <span className="text-xs text-amber-700 bg-amber-100 px-2 py-1 rounded">Snap to start available</span>}
</div>
<p className="text-xs text-green-700 mt-2">Walk to each corner, tap Mark Point, then Complete. You can refine points afterward.</p>
</div>
)}
{isGPSTraceMode && (
<div className="p-4 bg-green-50 border-t">
<div className="flex flex-wrap items-center gap-3 text-sm text-green-900">
<span className="font-semibold">Trace Mode</span>
<span>Points: {gpsTracePoints.length}</span>
<span>Distance: {(gpsDistance * 3.28084).toFixed(0)} ft</span>
{gpsAccuracy != null && <span>Accuracy: ±{Math.round(gpsAccuracy)} m</span>}
</div>
<div className="mt-3 flex gap-2 items-center">
{!isTracing ? (
<button className="btn-primary" onClick={() => {
if (!navigator.geolocation) { toast.error('GPS not available'); return; }
const id = navigator.geolocation.watchPosition((pos) => {
const { latitude, longitude, accuracy } = pos.coords;
setGpsAccuracy(accuracy || null);
setGpsTracePoints(prev => {
const [sLat, sLng] = addSampleAndSmooth(latitude, longitude, accuracy);
const normalized = acceptAndNormalizePoint(sLat, sLng, accuracy, prev);
if (prev.length >= 2) {
const [slat, slng] = prev[0];
const dStart = haversine(slat, slng, sLat, sLng);
setIsSnapPreview(dStart <= SNAP_METERS);
} else { setIsSnapPreview(false); }
if (!normalized) return prev;
const next = [...prev, normalized];
if (next.length > 1) {
const [pl, pg] = next[next.length - 2];
setGpsDistance(d => d + haversine(pl, pg, normalized[0], normalized[1]));
}
return next;
});
}, (err)=>{
console.warn('GPS error', err?.message);
toast.error('GPS error: ' + (err?.message || 'unknown'));
}, { enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 });
setGpsWatchId(id);
setIsTracing(true);
}}>Start</button>
) : (
<button className="btn-secondary" onClick={() => {
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
setIsTracing(false);
}}>Pause</button>
)}
<button className="btn-secondary" onClick={() => { if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null);} setIsTracing(false); clearGpsPoints(); }} disabled={gpsTracePoints.length===0}>Clear</button>
<button className="btn-primary" onClick={completeTracing} disabled={gpsTracePoints.length < 3}>Complete Boundary</button>
{isSnapPreview && <span className="text-xs text-amber-700 bg-amber-100 px-2 py-1 rounded">Snap to start available</span>}
</div>
<p className="text-xs text-green-700 mt-2">Walk the boundary to trace it. Pause as needed, then Complete to create the area.</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>
{(section.grassTypes && section.grassTypes.length > 0) || section.grassType ? (
<div className="flex flex-wrap gap-1 mt-1">
{((section.grassTypes && section.grassTypes.length > 0) ? section.grassTypes : (section.grassType||'').split(',').map(s=>s.trim()).filter(Boolean)).map((g, idx)=>(
<span key={idx} className="px-1.5 py-0.5 bg-green-100 text-green-800 rounded text-[10px]">{g}</span>
))}
</div>
) : null}
</div>
</div>
<div className="flex gap-1">
<button
onClick={() => startEditSection(section)}
className="text-blue-600 hover:text-blue-800"
>
<PencilIcon className="h-4 w-4" />
</button>
<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>
</div>
{/* Capture Mode Modal */}
{showCaptureModeModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-lg p-6 w-96 max-w-[92vw] shadow-2xl">
<h3 className="text-lg font-semibold mb-4">Create Lawn Section</h3>
<div className="space-y-3">
<button
className="w-full px-4 py-3 border rounded hover:bg-gray-50 text-left"
onClick={() => {
setShowCaptureModeModal(false);
setIsDrawing(true);
setIsGPSPointsMode(false);
setIsGPSTraceMode(false);
}}
>
<div className="font-medium">Tap to Draw (manual)</div>
<div className="text-xs text-gray-600">Tap the map to add corners, doubletap to finish.</div>
</button>
<button
className="w-full px-4 py-3 border rounded hover:bg-gray-50 text-left"
onClick={() => {
setShowCaptureModeModal(false);
setIsGPSTraceMode(false);
setIsTracing(false);
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
clearGpsPoints();
setIsGPSPointsMode(true);
// Start passive GPS watch to warm up accuracy for Points mode
if (navigator.geolocation) {
const id = navigator.geolocation.watchPosition((pos)=>{
const { latitude, longitude, accuracy } = pos.coords;
setGpsAccuracy(accuracy || null);
// Populate smoothing buffer without adding a point yet
try { addSampleAndSmooth(latitude, longitude, accuracy); } catch {}
}, (err)=>{
console.warn('GPS warmup error', err?.message);
}, { enableHighAccuracy: true, maximumAge: 1000, timeout: 15000 });
setGpsWatchId(id);
}
}}
>
<div className="font-medium">GPS Points (walk and mark)</div>
<div className="text-xs text-gray-600">Walk to each corner and tap Mark Point, then Complete.</div>
</button>
<button
className="w-full px-4 py-3 border rounded hover:bg-gray-50 text-left"
onClick={() => {
setShowCaptureModeModal(false);
setIsGPSPointsMode(false);
clearGpsPoints();
setIsGPSTraceMode(true);
}}
>
<div className="font-medium">Trace Boundary (continuous)</div>
<div className="text-xs text-gray-600">Start tracing and walk the perimeter. Pause as needed, then Complete.</div>
</button>
</div>
<div className="mt-4 flex justify-end">
<button className="btn-secondary" onClick={() => setShowCaptureModeModal(false)}>Cancel</button>
</div>
</div>
</div>
)}
{/* Recent History */}
<div className="mt-8">
<h2 className="text-lg font-semibold mb-3">Recent History</h2>
{historyLoading ? (
<div className="card p-4"><div className="text-gray-600">Loading</div></div>
) : unifiedHistory.length === 0 ? (
<div className="card p-4 text-gray-600">No history yet for this property.</div>
) : (
<div className="grid gap-4">
{unifiedHistory.map((item) => {
if (item.kind === 'application') {
const application = item.application; const log = item.log;
return (
<div key={`app-${application.id}`} className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-gray-900">{application.propertyName} - {application.sectionNames}</h3>
<span className="px-2 py-0.5 text-xs rounded-full bg-indigo-100 text-indigo-800">Application</span>
</div>
<span className={`px-3 py-1 text-sm font-medium rounded-full ${application.status==='archived'?'bg-gray-100 text-gray-800':'bg-green-100 text-green-800'}`}>{application.status==='archived'?'Archived':'Completed'}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center text-sm text-gray-600"><CalendarIcon className="h-4 w-4 mr-2" />{new Date(item.date).toLocaleString()}</div>
<div className="flex items-center text-sm text-gray-600"><MapPinIcon className="h-4 w-4 mr-2" />{application.propertyName}</div>
<div className="flex items-center text-sm text-gray-600"><WrenchScrewdriverIcon className="h-4 w-4 mr-2" />{application.equipmentName}</div>
</div>
{log?.gpsTrack && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
<div className="bg-blue-50 p-2 rounded text-center">
<div className="text-xs text-blue-600 font-medium">Duration</div>
<div className="text-sm font-bold text-blue-900">{Math.round((log.gpsTrack?.duration||0)/60)} min</div>
</div>
<div className="bg-green-50 p-2 rounded text-center">
<div className="text-xs text-green-600 font-medium">GPS Points</div>
<div className="text-sm font-bold text-green-900">{log.gpsTrack?.points?.length || 0}</div>
</div>
<div className="bg-purple-50 p-2 rounded text-center">
<div className="text-xs text-purple-600 font-medium">Distance</div>
<div className="text-sm font-bold text-purple-900">{Math.round(log.gpsTrack?.totalDistance||0)} ft</div>
</div>
<div className="bg-orange-50 p-2 rounded text-center">
<div className="text-xs text-orange-600 font-medium">Coverage</div>
<div className="text-sm font-bold text-orange-900">{calculateCoverage(application, log)}%</div>
</div>
</div>
)}
</div>
<button onClick={()=> setViewingApplication(application)} className="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded" title="View details"><EyeIcon className="h-5 w-5" /></button>
</div>
</div>
);
}
const log = item.log;
const durationMin = Math.round((log.duration_seconds || log.durationSeconds || log.gpsTrack?.duration || log.gps_track?.duration || 0)/60);
const distFeet = Math.round(((log.total_distance_meters || log.gpsTrack?.totalDistance || log.gps_track?.totalDistance || 0)*3.28084)||0);
return (
<div key={`mow-${log.id}`} className="bg-white p-6 rounded-lg shadow">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h3 className="text-lg font-semibold text-gray-900">{log.property_name} - {log.section_names}</h3>
<span className="px-2 py-0.5 text-xs rounded-full bg-green-100 text-green-800">Mowing</span>
</div>
<span className="px-3 py-1 text-sm font-medium rounded-full bg-green-100 text-green-800">Completed</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div className="flex items-center text-sm text-gray-600"><CalendarIcon className="h-4 w-4 mr-2" />{new Date(item.date).toLocaleString()}</div>
<div className="flex items-center text-sm text-gray-600"><MapPinIcon className="h-4 w-4 mr-2" />{log.property_name}</div>
<div className="flex items-center text-sm text-gray-600"><WrenchScrewdriverIcon className="h-4 w-4 mr-2" />{log.equipment_name || 'Mower'}</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 mb-4">
<div className="bg-blue-50 p-2 rounded text-center"><div className="text-xs text-blue-600 font-medium">Duration</div><div className="text-sm font-bold text-blue-900">{durationMin} min</div></div>
<div className="bg-green-50 p-2 rounded text-center"><div className="text-xs text-green-600 font-medium">GPS Points</div><div className="text-sm font-bold text-green-900">{log.gpsTrack?.points?.length || log.gps_track?.points?.length || 0}</div></div>
<div className="bg-purple-50 p-2 rounded text-center"><div className="text-xs text-purple-600 font-medium">Distance</div><div className="text-sm font-bold text-purple-900">{distFeet} ft</div></div>
<div className="bg-orange-50 p-2 rounded text-center"><div className="text-xs text-orange-600 font-medium">Coverage</div><div className="text-sm font-bold text-orange-900">{calculateMowingCoverage(log)}%</div></div>
</div>
</div>
<button onClick={()=> setViewingMowingSession(log)} className="p-2 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded" title="View mowing session"><EyeIcon className="h-5 w-5" /></button>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Name Modal */}
{showNameModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-lg p-6 w-96 shadow-2xl">
<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>
{/* Nudge controls */}
<div>
<label className="label">Adjust Position (1 ft)</label>
<div className="grid grid-cols-3 gap-2 w-40">
<div></div>
<button className="btn-secondary" onClick={() => {
setPendingSection(prev => {
if (!prev) return prev;
const lat = prev.coordinates[0][0];
const metersPerDegLat = 111320;
const dLat = (1 * 0.3048) / metersPerDegLat; // 1 ft north
const coords = prev.coordinates.map(([la, ln]) => [la + dLat, ln]);
return { ...prev, coordinates: coords };
});
}}></button>
<div></div>
<button className="btn-secondary" onClick={() => {
setPendingSection(prev => {
if (!prev) return prev;
const lat = prev.coordinates[0][0];
const metersPerDegLng = 111320 * Math.cos(lat * Math.PI / 180);
const dLng = (1 * 0.3048) / metersPerDegLng; // 1 ft west
const coords = prev.coordinates.map(([la, ln]) => [la, ln - dLng]);
return { ...prev, coordinates: coords };
});
}}></button>
<div></div>
<button className="btn-secondary" onClick={() => {
setPendingSection(prev => {
if (!prev) return prev;
const lat = prev.coordinates[0][0];
const metersPerDegLng = 111320 * Math.cos(lat * Math.PI / 180);
const dLng = (1 * 0.3048) / metersPerDegLng; // 1 ft east
const coords = prev.coordinates.map(([la, ln]) => [la, ln + dLng]);
return { ...prev, coordinates: coords };
});
}}></button>
<div></div>
<button className="btn-secondary" onClick={() => {
setPendingSection(prev => {
if (!prev) return prev;
const dLat = (1 * 0.3048) / 111320; // 1 ft south
const coords = prev.coordinates.map(([la, ln]) => [la - dLat, ln]);
return { ...prev, coordinates: coords };
});
}}></button>
<div></div>
</div>
</div>
<div>
<label className="label">Grass Types</label>
<TagInput
value={sectionGrassTypes}
onChange={setSectionGrassTypes}
/>
<SuggestionChips onPick={(v)=> setSectionGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} />
</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('');
setSectionGrassTypes([]);
}}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* Edit Modal */}
{showEditModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center" style={{ zIndex: 9999 }}>
<div className="bg-white rounded-lg p-6 w-96 shadow-2xl">
<h3 className="text-lg font-semibold mb-4">Edit Lawn Section</h3>
<div className="space-y-4">
<div>
<label className="label">Section Name</label>
<input
type="text"
className="input"
value={sectionName}
onChange={(e) => setSectionName(e.target.value)}
placeholder="e.g., Front Yard, Back Lawn"
autoFocus
/>
</div>
<div>
<label className="label">Color</label>
<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>
<div>
<label className="label">Grass Types</label>
<TagInput
value={editGrassTypes}
onChange={setEditGrassTypes}
/>
<SuggestionChips onPick={(v)=> setEditGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} />
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: currentColor.value }}
/>
<span>{editingSection?.area.toLocaleString()} sq ft</span>
</div>
</div>
<div className="flex gap-3 mt-6">
<button onClick={saveEditedSection} className="btn-primary flex-1">
Save Changes
</button>
<button
onClick={() => {
setShowEditModal(false);
setEditingSection(null);
setSectionName('');
setEditGrassTypes([]);
}}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</div>
</div>
)}
{/* View Modals for History */}
{viewingApplication && (
<ApplicationViewModal
application={viewingApplication}
propertyDetails={property}
onClose={() => setViewingApplication(null)}
/>
)}
{viewingMowingSession && (
<MowingSessionViewModal
session={viewingMowingSession}
onClose={() => setViewingMowingSession(null)}
/>
)}
</div>
);
};
export default PropertyDetail;