1316 lines
54 KiB
JavaScript
1316 lines
54 KiB
JavaScript
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 [gpsDistance, setGpsDistance] = useState(0);
|
||
const [gpsAccuracy, setGpsAccuracy] = useState(null);
|
||
const [isSnapPreview, setIsSnapPreview] = 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);
|
||
};
|
||
|
||
// 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 = 15; // Ignore points with accuracy worse than this
|
||
const MIN_MOVE_METERS = 2; // Ignore points if user hasn't moved this far from last point
|
||
const SNAP_METERS = 5; // Snap to starting point when within this distance
|
||
|
||
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) {
|
||
// Too close to last point
|
||
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; }
|
||
navigator.geolocation.getCurrentPosition((pos) => {
|
||
const { latitude, longitude, accuracy } = pos.coords;
|
||
setGpsAccuracy(accuracy || null);
|
||
setGpsTracePoints(prev => {
|
||
const normalized = acceptAndNormalizePoint(latitude, longitude, accuracy, prev);
|
||
// For preview: check proximity to start even if skipping
|
||
if (prev.length >= 2) {
|
||
const [slat, slng] = prev[0];
|
||
const dStart = haversine(slat, slng, latitude, longitude);
|
||
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;
|
||
});
|
||
}, (err)=>{
|
||
console.warn('GPS error', err?.message);
|
||
toast.error('GPS error: ' + (err?.message || 'unknown'));
|
||
}, { enableHighAccuracy: true, maximumAge: 1000, timeout: 10000 });
|
||
};
|
||
|
||
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);
|
||
};
|
||
|
||
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 {
|
||
const sectionData = {
|
||
name: sectionName,
|
||
area: pendingSection.area,
|
||
polygonData: {
|
||
coordinates: [pendingSection.coordinates],
|
||
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
|
||
const newSection = {
|
||
id: savedSection.id,
|
||
name: savedSection.name,
|
||
coordinates: savedSection.polygonData.coordinates[0],
|
||
color: savedSection.polygonData.color,
|
||
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('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="flex gap-2">
|
||
<button
|
||
onClick={() => setIsDrawing(!isDrawing)}
|
||
className={`btn-primary flex items-center gap-2 ${isDrawing ? 'bg-red-600 hover:bg-red-700' : ''}`}
|
||
>
|
||
<PlusIcon className="h-5 w-5" />
|
||
{isDrawing ? 'Cancel Drawing' : 'Tap-to-Draw'}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
// Toggle GPS points mode; ensure trace mode off
|
||
setIsGPSTraceMode(false);
|
||
setIsTracing(false);
|
||
if (gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); }
|
||
setIsGPSPointsMode(v=>!v);
|
||
if (!isGPSPointsMode) clearGpsPoints();
|
||
}}
|
||
className={`btn-secondary flex items-center gap-2 ${isGPSPointsMode ? 'ring-2 ring-blue-300' : ''}`}
|
||
>
|
||
{isGPSPointsMode ? 'Exit GPS Points' : 'GPS Points'}
|
||
</button>
|
||
<button
|
||
onClick={() => {
|
||
// Toggle trace mode; ensure points mode off
|
||
setIsGPSPointsMode(false);
|
||
if (!isGPSTraceMode) clearGpsPoints();
|
||
if (isGPSTraceMode && gpsWatchId) { navigator.geolocation.clearWatch(gpsWatchId); setGpsWatchId(null); setIsTracing(false); }
|
||
setIsGPSTraceMode(v=>!v);
|
||
}}
|
||
className={`btn-secondary flex items-center gap-2 ${isGPSTraceMode ? 'ring-2 ring-blue-300' : ''}`}
|
||
>
|
||
{isGPSTraceMode ? 'Exit Trace' : 'Trace Boundary'}
|
||
</button>
|
||
</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='© <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 normalized = acceptAndNormalizePoint(latitude, longitude, accuracy, prev);
|
||
if (prev.length >= 2) {
|
||
const [slat, slng] = prev[0];
|
||
const dStart = haversine(slat, slng, latitude, longitude);
|
||
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>
|
||
|
||
{/* 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>
|
||
<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;
|