import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { MapContainer, TileLayer, Marker, Popup, Polygon, 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);
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 => ({
id: section.id,
name: section.name,
coordinates: section.polygonData?.coordinates?.[0] || [],
color: section.polygonData?.color || SECTION_COLORS[0],
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);
};
const saveLawnSection = async () => {
if (!sectionName.trim()) {
toast.error('Please enter a section name');
return;
}
try {
const sectionData = {
name: sectionName,
area: pendingSection.area,
polygonData: {
coordinates: [pendingSection.coordinates],
color: pendingSection.color
},
grassType: sectionGrassTypes.join(', '),
grassTypes: sectionGrassTypes,
soilType: null
};
const response = await propertiesAPI.createSection(id, sectionData);
const savedSection = response.data.data.section;
// Convert backend format to frontend format
const newSection = {
id: savedSection.id,
name: savedSection.name,
coordinates: savedSection.polygonData.coordinates[0],
color: savedSection.polygonData.color,
area: savedSection.area
};
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('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
};
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
)}
{/* Map */}
{hasValidCoordinates && (
{property.name}
)}
{lawnSections.map((section) => (
))}
{isDrawing && (
)}
{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
)}
{/* 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}
))}
)}
{/* 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
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;