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 (
);
};
const SuggestionChips = ({ onPick }) => (
{COOL_SEASON_GRASSES.map(g => (
))}
);
// 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 ? (
) : 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 (
<>
{
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');
}
}
}}
>
{section.name}
{section.area.toLocaleString()} sq ft
{/* Editable markers for each point */}
{isEditing && editedCoordinates.map((coord, index) => (
{
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(`
`),
iconSize: [12, 12],
iconAnchor: [6, 6]
})}
>
Point {index + 1}
))}
>
);
}
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 {
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 (
);
}
if (!property) {
return (
Property Not Found
);
}
// 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 (
{/* Header */}
{property.name}
{property.address || 'No address specified'}
{!hasValidCoordinates && (
⚠️ Property coordinates not set - using default location
)}
{/* Dropdown removed in favor of modal for reliability */}
{/* Map */}
{hasValidCoordinates && (
{property.name}
)}
{lawnSections.map((section) => (
))}
{isDrawing && (
)}
{/* GPS trace overlays */}
{(isGPSPointsMode || isGPSTraceMode) && gpsTracePoints.length > 0 && (
<>
{gpsTracePoints.map((p, i) => (
${i===0 && isSnapPreview ? '' : ''}
`),
iconSize: [14,14], iconAnchor: [7,7]
})}
/>
))}
>
)}
{isDrawing && (
Drawing Mode Active: Click to add points. Double-click to complete polygon.
Need at least 3 points to create a section. Press ESC to cancel.
💡 After creating: Click any polygon to edit its points by dragging
)}
{isGPSPointsMode && (
GPS Points Mode
Points: {gpsTracePoints.length}
Distance: {(gpsDistance * 3.28084).toFixed(0)} ft
{gpsAccuracy != null && Accuracy: ±{Math.round(gpsAccuracy)} m}
{isSnapPreview && Snap to start available}
Walk to each corner, tap Mark Point, then Complete. You can refine points afterward.
)}
{isGPSTraceMode && (
Trace Mode
Points: {gpsTracePoints.length}
Distance: {(gpsDistance * 3.28084).toFixed(0)} ft
{gpsAccuracy != null && Accuracy: ±{Math.round(gpsAccuracy)} m}
{!isTracing ? (
) : (
)}
{isSnapPreview && Snap to start available}
Walk the boundary to trace it. Pause as needed, then Complete to create the area.
)}
{/* Sidebar */}
{/* Color Selector */}
{isDrawing && (
Section Color
{SECTION_COLORS.map((color) => (
)}
{/* Property Summary */}
Property Summary
Total Sections:
{lawnSections.length}
Total Area:
{getTotalArea().toLocaleString()} sq ft
{/* Lawn Sections */}
Lawn Sections
{lawnSections.length === 0 ? (
No sections yet.
) : (
{lawnSections.map((section) => (
{section.name}
{section.area.toLocaleString()} sq ft
{(section.grassTypes && section.grassTypes.length > 0) || section.grassType ? (
{((section.grassTypes && section.grassTypes.length > 0) ? section.grassTypes : (section.grassType||'').split(',').map(s=>s.trim()).filter(Boolean)).map((g, idx)=>(
{g}
))}
) : null}
))}
)}
{/* Capture Mode Modal */}
{showCaptureModeModal && (
Create Lawn Section
)}
{/* Recent History */}
Recent History
{historyLoading ? (
) : unifiedHistory.length === 0 ? (
No history yet for this property.
) : (
{unifiedHistory.map((item) => {
if (item.kind === 'application') {
const application = item.application; const log = item.log;
return (
{application.propertyName} - {application.sectionNames}
Application
{application.status==='archived'?'Archived':'Completed'}
{new Date(item.date).toLocaleString()}
{application.propertyName}
{application.equipmentName}
{log?.gpsTrack && (
Duration
{Math.round((log.gpsTrack?.duration||0)/60)} min
GPS Points
{log.gpsTrack?.points?.length || 0}
Distance
{Math.round(log.gpsTrack?.totalDistance||0)} ft
Coverage
{calculateCoverage(application, log)}%
)}
);
}
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 (
{log.property_name} - {log.section_names}
Mowing
Completed
{new Date(item.date).toLocaleString()}
{log.property_name}
{log.equipment_name || 'Mower'}
Duration
{durationMin} min
GPS Points
{log.gpsTrack?.points?.length || log.gps_track?.points?.length || 0}
Coverage
{calculateMowingCoverage(log)}%
);
})}
)}
{/* Name Modal */}
{showNameModal && (
Name Your Lawn Section
setSectionName(e.target.value)}
placeholder="e.g., Front Yard, Back Lawn"
autoFocus
/>
{pendingSection?.area.toLocaleString()} sq ft
{/* Nudge controls */}
setSectionGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} />
)}
{/* Edit Modal */}
{showEditModal && (
Edit Lawn Section
setSectionName(e.target.value)}
placeholder="e.g., Front Yard, Back Lawn"
autoFocus
/>
{SECTION_COLORS.map((color) => (
setEditGrassTypes(prev=> prev.includes(v)? prev : [...prev, v])} />
{editingSection?.area.toLocaleString()} sq ft
)}
{/* View Modals for History */}
{viewingApplication && (
setViewingApplication(null)}
/>
)}
{viewingMowingSession && (
setViewingMowingSession(null)}
/>
)}
);
};
export default PropertyDetail;