perform applications

This commit is contained in:
Jake Kasper
2025-08-27 09:10:43 -04:00
parent abff69cad6
commit a6d3435f1c
3 changed files with 664 additions and 4 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback, useRef } from 'react';
import { MapContainer, TileLayer, Polygon, Marker, useMapEvents } from 'react-leaflet';
import { MapContainer, TileLayer, Polygon, Marker, Polyline, useMapEvents } from 'react-leaflet';
import { Icon } from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -11,6 +11,28 @@ Icon.Default.mergeOptions({
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
});
// Custom GPS tracking icons
const currentLocationIcon = new Icon({
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="8" fill="#3B82F6" stroke="white" stroke-width="2"/>
<circle cx="10" cy="10" r="4" fill="white"/>
</svg>
`),
iconSize: [20, 20],
iconAnchor: [10, 10],
});
const trackPointIcon = new Icon({
iconUrl: 'data:image/svg+xml;base64,' + btoa(`
<svg xmlns="http://www.w3.org/2000/svg" width="6" height="6" viewBox="0 0 6 6">
<circle cx="3" cy="3" r="2" fill="#10B981"/>
</svg>
`),
iconSize: [6, 6],
iconAnchor: [3, 3],
});
// Custom component to handle map clicks for drawing polygons
const DrawingHandler = ({ isDrawing, onPointAdd, onDrawingComplete }) => {
useMapEvents({
@@ -64,7 +86,12 @@ const PropertyMap = ({
onSectionClick,
selectedSections = [],
editable = false,
className = "h-96 w-full"
className = "h-96 w-full",
// GPS tracking props
mode = "view", // "view", "edit", "execution"
gpsTrack = [],
currentLocation = null,
showTrackPoints = true
}) => {
const [isDrawing, setIsDrawing] = useState(false);
const [currentPolygon, setCurrentPolygon] = useState([]);
@@ -390,6 +417,41 @@ const PropertyMap = ({
);
})}
{/* GPS Tracking Elements */}
{mode === "execution" && (
<>
{/* GPS Track Polyline */}
{gpsTrack.length > 1 && (
<Polyline
positions={gpsTrack.map(point => [point.lat, point.lng])}
pathOptions={{
color: '#10B981',
weight: 4,
opacity: 0.8,
}}
/>
)}
{/* Track Points (breadcrumbs) */}
{showTrackPoints && gpsTrack.map((point, index) => (
index % 10 === 0 && ( // Show every 10th point to avoid clutter
<Marker
key={`track-${index}`}
position={[point.lat, point.lng]}
icon={trackPointIcon}
/>
)
))}
{/* Current Location */}
{currentLocation && (
<Marker
position={[currentLocation.lat, currentLocation.lng]}
icon={currentLocationIcon}
/>
)}
</>
)}
{/* Current polygon being drawn */}
{currentPolygon.length > 0 && (
@@ -440,8 +502,25 @@ const PropertyMap = ({
</div>
)}
{/* GPS Tracking stats */}
{mode === "execution" && gpsTrack.length > 0 && (
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3">
<p className="text-sm font-medium text-gray-700">
GPS Tracking Active
</p>
<p className="text-xs text-gray-600">
Track Points: {gpsTrack.length}
</p>
{currentLocation && (
<p className="text-xs text-gray-600">
Accuracy: ±{Math.round(currentLocation.accuracy || 0)}m
</p>
)}
</div>
)}
{/* Section stats */}
{sections.length > 0 && !isDrawing && (
{sections.length > 0 && !isDrawing && mode !== "execution" && (
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3">
<p className="text-sm font-medium text-gray-700">
{sections.length} Section{sections.length !== 1 ? 's' : ''}

View File

@@ -6,7 +6,8 @@ import {
WrenchScrewdriverIcon,
CalculatorIcon,
PencilIcon,
TrashIcon
TrashIcon,
PlayIcon
} from '@heroicons/react/24/outline';
import { propertiesAPI, productsAPI, equipmentAPI, applicationsAPI, nozzlesAPI } from '../../services/api';
import LoadingSpinner from '../../components/UI/LoadingSpinner';
@@ -26,6 +27,14 @@ const Applications = () => {
const [propertyCache, setPropertyCache] = useState({});
const [spreaderRecommendation, setSpreaderRecommendation] = useState(null);
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
// Application execution state
const [executingApplication, setExecutingApplication] = useState(null);
const [showExecutionModal, setShowExecutionModal] = useState(false);
const [isTracking, setIsTracking] = useState(false);
const [gpsTrack, setGpsTrack] = useState([]);
const [currentSpeed, setCurrentSpeed] = useState(0);
const [currentLocation, setCurrentLocation] = useState(null);
useEffect(() => {
fetchApplications();
@@ -129,6 +138,23 @@ const Applications = () => {
}
};
const handleExecuteApplication = async (application) => {
try {
// Set the executing application and show the modal
setExecutingApplication(application);
// Also fetch the property details if we don't have them
if (!selectedPropertyDetails || selectedPropertyDetails.id !== application.property?.id) {
await fetchPropertyDetails(application.property?.id || application.section?.propertyId);
}
setShowExecutionModal(true);
} catch (error) {
console.error('Failed to start application execution:', error);
toast.error('Failed to start application execution');
}
};
if (loading) {
return (
<div className="p-6">
@@ -364,6 +390,15 @@ const Applications = () => {
Created {new Date(application.createdAt).toLocaleDateString()}
</p>
<div className="flex gap-2 mt-2">
{application.status === 'planned' && (
<button
onClick={() => handleExecuteApplication(application)}
className="p-1 text-green-600 hover:text-green-800 hover:bg-green-50 rounded"
title="Execute application"
>
<PlayIcon className="h-4 w-4" />
</button>
)}
<button
onClick={() => handleEditPlan(application.id)}
className="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded"
@@ -1366,6 +1401,367 @@ const ApplicationPlanModal = ({
</div>
</div>
)}
{/* Application Execution Modal */}
{showExecutionModal && executingApplication && (
<ApplicationExecutionModal
application={executingApplication}
propertyDetails={selectedPropertyDetails}
onClose={() => {
setShowExecutionModal(false);
setExecutingApplication(null);
setIsTracking(false);
setGpsTrack([]);
setCurrentLocation(null);
setCurrentSpeed(0);
}}
onComplete={async (logData) => {
try {
// Save the application log to the backend
await applicationsAPI.createLog(logData);
toast.success('Application completed successfully');
// Refresh applications list
fetchApplications();
// Close modal
setShowExecutionModal(false);
setExecutingApplication(null);
setIsTracking(false);
setGpsTrack([]);
setCurrentLocation(null);
setCurrentSpeed(0);
} catch (error) {
console.error('Failed to save application log:', error);
toast.error('Failed to save application log');
}
}}
/>
)}
</div>
</div>
);
};
// Application Execution Modal Component
const ApplicationExecutionModal = ({ application, propertyDetails, onClose, onComplete }) => {
const [isTracking, setIsTracking] = useState(false);
const [gpsTrack, setGpsTrack] = useState([]);
const [currentLocation, setCurrentLocation] = useState(null);
const [currentSpeed, setCurrentSpeed] = useState(0);
const [startTime, setStartTime] = useState(null);
const [watchId, setWatchId] = useState(null);
const [previousLocation, setPreviousLocation] = useState(null);
const [previousTime, setPreviousTime] = useState(null);
const [totalDistance, setTotalDistance] = useState(0);
const [averageSpeed, setAverageSpeed] = useState(0);
// Calculate target speed for liquid applications
const targetSpeed = React.useMemo(() => {
if (!application.products || application.products.length === 0) return null;
// Find the first liquid product to get equipment specs
const firstProduct = application.products[0];
if (firstProduct.applicationType !== 'liquid') return null;
// This would be calculated based on equipment specs and application rate
// For now, return a default target speed - this should be calculated from the plan
return 3.0; // 3.0 mph default target
}, [application]);
const speedStatus = React.useMemo(() => {
if (!targetSpeed || !currentSpeed) return 'normal';
const tolerance = 0.1; // 10% tolerance
const lowerBound = targetSpeed * (1 - tolerance);
const upperBound = targetSpeed * (1 + tolerance);
if (currentSpeed < lowerBound) return 'slow';
if (currentSpeed > upperBound) return 'fast';
return 'normal';
}, [currentSpeed, targetSpeed]);
// Start GPS tracking
const startTracking = () => {
if (!navigator.geolocation) {
toast.error('GPS not available on this device');
return;
}
setIsTracking(true);
setStartTime(new Date());
setGpsTrack([]);
setTotalDistance(0);
setPreviousLocation(null);
setPreviousTime(null);
const watchId = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude, accuracy, speed } = position.coords;
const timestamp = new Date(position.timestamp);
const newLocation = {
lat: latitude,
lng: longitude,
accuracy,
timestamp: timestamp.toISOString(),
speed: speed || 0
};
setCurrentLocation(newLocation);
// Calculate speed if we have a previous location
if (previousLocation && previousTime) {
const distance = calculateDistance(
previousLocation.lat, previousLocation.lng,
latitude, longitude
);
const timeDiff = (timestamp - previousTime) / 1000; // seconds
if (timeDiff > 0) {
const speedMph = (distance / timeDiff) * 2.237; // Convert m/s to mph
setCurrentSpeed(speedMph);
setTotalDistance(prev => prev + distance);
// Update average speed
const totalTime = (timestamp - startTime) / 1000; // seconds
if (totalTime > 0) {
const avgSpeedMph = (totalDistance + distance) / totalTime * 2.237;
setAverageSpeed(avgSpeedMph);
}
}
}
setPreviousLocation({ lat: latitude, lng: longitude });
setPreviousTime(timestamp);
setGpsTrack(prev => [...prev, newLocation]);
},
(error) => {
console.error('GPS error:', error);
toast.error(`GPS error: ${error.message}`);
},
{
enableHighAccuracy: true,
timeout: 5000,
maximumAge: 1000
}
);
setWatchId(watchId);
};
// Stop GPS tracking
const stopTracking = () => {
if (watchId) {
navigator.geolocation.clearWatch(watchId);
setWatchId(null);
}
setIsTracking(false);
};
// Complete application
const completeApplication = () => {
stopTracking();
const endTime = new Date();
const duration = startTime ? (endTime - startTime) / 1000 : 0; // seconds
const logData = {
planId: application.id,
lawnSectionId: application.sections?.[0]?.id || application.section?.id, // Use first section for multi-area plans
equipmentId: application.equipment?.id,
applicationDate: endTime.toISOString(),
gpsTrack: gpsTrack,
averageSpeed: averageSpeed,
areaCovered: application.totalSectionArea || application.sectionArea || 0,
notes: `Application completed via mobile tracking. Duration: ${Math.round(duration/60)} minutes`,
products: application.products?.map(product => ({
productId: product.productId,
userProductId: product.userProductId,
rateAmount: product.rateAmount,
rateUnit: product.rateUnit,
actualProductAmount: product.actualProductAmount || product.productAmount,
actualWaterAmount: product.actualWaterAmount || product.waterAmount,
actualSpeedMph: averageSpeed
})) || []
};
onComplete(logData);
};
// Calculate distance between two points in meters
const calculateDistance = (lat1, lng1, lat2, lng2) => {
const R = 6371e3; // Earth's radius in meters
const φ1 = lat1 * Math.PI/180;
const φ2 = lat2 * Math.PI/180;
const Δφ = (lat2-lat1) * Math.PI/180;
const Δλ = (lng2-lng1) * Math.PI/180;
const a = Math.sin(Δφ/2) * Math.sin(Δφ/2) +
Math.cos(φ1) * Math.cos(φ2) *
Math.sin(Δλ/2) * Math.sin(Δλ/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
};
// Cleanup on unmount
React.useEffect(() => {
return () => {
if (watchId) {
navigator.geolocation.clearWatch(watchId);
}
};
}, [watchId]);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold">Execute Application</h3>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700">
</button>
</div>
{/* Application Details */}
<div className="bg-gray-50 p-4 rounded-lg mb-6">
<h4 className="font-medium mb-2">Application Details</h4>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Property:</span> {application.propertyName}
</div>
<div>
<span className="font-medium">Areas:</span> {application.sectionNames}
</div>
<div>
<span className="font-medium">Equipment:</span> {application.equipmentName}
</div>
<div>
<span className="font-medium">Area:</span> {Math.round(application.totalSectionArea || application.sectionArea || 0).toLocaleString()} sq ft
</div>
</div>
{/* Products */}
<div className="mt-4">
<h5 className="font-medium mb-2">Products to Apply:</h5>
<div className="space-y-2">
{application.products?.map((product, index) => (
<div key={index} className="bg-white p-2 rounded border text-sm">
<div className="font-medium">{product.productName}</div>
<div className="text-gray-600">
Rate: {product.rateAmount} {product.rateUnit}
{product.productAmount && ` • Product: ${product.productAmount}`}
{product.waterAmount && ` • Water: ${product.waterAmount}`}
</div>
</div>
))}
</div>
</div>
</div>
{/* Speed Guidance for Liquid Applications */}
{targetSpeed && (
<div className={`p-4 rounded-lg mb-6 ${
speedStatus === 'normal' ? 'bg-green-50 border border-green-200' :
speedStatus === 'slow' ? 'bg-yellow-50 border border-yellow-200' :
'bg-red-50 border border-red-200'
}`}>
<div className="flex items-center justify-between">
<div>
<h4 className="font-medium">Speed Guidance</h4>
<p className="text-sm text-gray-600">
Target: {targetSpeed.toFixed(1)} mph Current: {currentSpeed.toFixed(1)} mph
</p>
</div>
<div className={`text-lg font-bold ${
speedStatus === 'normal' ? 'text-green-600' :
speedStatus === 'slow' ? 'text-yellow-600' :
'text-red-600'
}`}>
{speedStatus === 'normal' ? '✓ Good Speed' :
speedStatus === 'slow' ? '↑ Go Faster' :
'↓ Slow Down'}
</div>
</div>
</div>
)}
{/* Map with GPS Track */}
<div className="mb-6">
<h4 className="font-medium mb-2">Application Area & GPS Track</h4>
<div className="h-96 border rounded-lg overflow-hidden">
{propertyDetails && (
<PropertyMap
property={propertyDetails}
selectedSections={application.sections?.map(s => s.id) || []}
mode="execution"
gpsTrack={gpsTrack}
currentLocation={currentLocation}
/>
)}
</div>
</div>
{/* Tracking Stats */}
{isTracking && (
<div className="bg-blue-50 p-4 rounded-lg mb-6">
<h4 className="font-medium mb-2">Tracking Statistics</h4>
<div className="grid grid-cols-4 gap-4 text-sm">
<div>
<span className="font-medium">Duration:</span><br/>
{startTime ? Math.round((new Date() - startTime) / 60000) : 0} min
</div>
<div>
<span className="font-medium">Distance:</span><br/>
{(totalDistance * 3.28084).toFixed(0)} ft
</div>
<div>
<span className="font-medium">Avg Speed:</span><br/>
{averageSpeed.toFixed(1)} mph
</div>
<div>
<span className="font-medium">Track Points:</span><br/>
{gpsTrack.length}
</div>
</div>
</div>
)}
{/* Action Buttons */}
<div className="flex gap-3">
{!isTracking ? (
<button
onClick={startTracking}
className="btn-primary flex-1"
>
Start Application
</button>
) : (
<>
<button
onClick={stopTracking}
className="btn-secondary flex-1"
>
Pause Tracking
</button>
<button
onClick={completeApplication}
className="btn-primary flex-1"
>
Complete Application
</button>
</>
)}
<button
onClick={onClose}
className="btn-secondary"
>
Cancel
</button>
</div>
</div>
</div>
);