From a6d3435f1c4a21924ac80a3f30c35c9842160a8e Mon Sep 17 00:00:00 2001 From: Jake Kasper Date: Wed, 27 Aug 2025 09:10:43 -0400 Subject: [PATCH] perform applications --- backend/src/routes/applications.js | 185 ++++++++ frontend/src/components/Maps/PropertyMap.js | 85 +++- .../src/pages/Applications/Applications.js | 398 +++++++++++++++++- 3 files changed, 664 insertions(+), 4 deletions(-) diff --git a/backend/src/routes/applications.js b/backend/src/routes/applications.js index cc4e687..0398500 100644 --- a/backend/src/routes/applications.js +++ b/backend/src/routes/applications.js @@ -1246,4 +1246,189 @@ router.get('/spreader-settings/:equipmentId/:productId', async (req, res, next) } }); +// @route POST /api/applications/logs +// @desc Create application log (actual execution) +// @access Private +router.post('/logs', validateRequest(applicationLogSchema), async (req, res, next) => { + try { + const { + planId, + lawnSectionId, + equipmentId, + applicationDate, + weatherConditions, + gpsTrack, + averageSpeed, + areaCovered, + notes, + products + } = req.body; + + // Verify plan belongs to user if planId is provided + if (planId) { + const planCheck = await pool.query(` + SELECT ap.* FROM application_plans ap + JOIN application_plan_sections aps ON ap.id = aps.plan_id + JOIN lawn_sections ls ON aps.lawn_section_id = ls.id + JOIN properties p ON ls.property_id = p.id + WHERE ap.id = $1 AND p.user_id = $2 + `, [planId, req.user.id]); + + if (planCheck.rows.length === 0) { + throw new AppError('Plan not found', 404); + } + } + + // Verify lawn section belongs to user + const sectionCheck = await pool.query(` + SELECT ls.* FROM lawn_sections ls + JOIN properties p ON ls.property_id = p.id + WHERE ls.id = $1 AND p.user_id = $2 + `, [lawnSectionId, req.user.id]); + + if (sectionCheck.rows.length === 0) { + throw new AppError('Lawn section not found', 404); + } + + // Verify equipment belongs to user + const equipmentCheck = await pool.query( + 'SELECT * FROM user_equipment WHERE id = $1 AND user_id = $2', + [equipmentId, req.user.id] + ); + + if (equipmentCheck.rows.length === 0) { + throw new AppError('Equipment not found', 404); + } + + // Create application log + const logResult = await pool.query(` + INSERT INTO application_logs + (plan_id, user_id, lawn_section_id, equipment_id, application_date, weather_conditions, gps_track, average_speed, area_covered, notes) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING * + `, [ + planId, + req.user.id, + lawnSectionId, + equipmentId, + applicationDate || new Date(), + weatherConditions ? JSON.stringify(weatherConditions) : null, + gpsTrack ? JSON.stringify(gpsTrack) : null, + averageSpeed, + areaCovered, + notes + ]); + + const log = logResult.rows[0]; + + // Create product log entries + if (products && products.length > 0) { + for (const product of products) { + // Verify product ownership if it's a user product + if (product.userProductId) { + const productCheck = await pool.query( + 'SELECT * FROM user_products WHERE id = $1 AND user_id = $2', + [product.userProductId, req.user.id] + ); + + if (productCheck.rows.length === 0) { + throw new AppError(`User product ${product.userProductId} not found`, 404); + } + } + + await pool.query(` + INSERT INTO application_log_products + (log_id, product_id, user_product_id, rate_amount, rate_unit, actual_product_amount, actual_water_amount, actual_speed_mph) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + `, [ + log.id, + product.productId || null, + product.userProductId || null, + product.rateAmount, + product.rateUnit, + product.actualProductAmount || null, + product.actualWaterAmount || null, + product.actualSpeedMph || null + ]); + } + } + + // Update plan status to completed if planId provided + if (planId) { + await pool.query( + 'UPDATE application_plans SET status = $1 WHERE id = $2', + ['completed', planId] + ); + } + + res.status(201).json({ + success: true, + message: 'Application log created successfully', + data: { + log: { + id: log.id, + planId: log.plan_id, + lawnSectionId: log.lawn_section_id, + equipmentId: log.equipment_id, + applicationDate: log.application_date, + averageSpeed: log.average_speed, + areaCovered: log.area_covered, + createdAt: log.created_at + } + } + }); + } catch (error) { + next(error); + } +}); + +// @route GET /api/applications/logs +// @desc Get application logs +// @access Private +router.get('/logs', async (req, res, next) => { + try { + const result = await pool.query(` + SELECT + al.*, + p.name as property_name, + p.address as property_address, + ls.name as section_name, + ue.custom_name as equipment_name, + et.name as equipment_type + FROM application_logs al + JOIN lawn_sections ls ON al.lawn_section_id = ls.id + JOIN properties p ON ls.property_id = p.id + JOIN user_equipment ue ON al.equipment_id = ue.id + LEFT JOIN equipment_types et ON ue.equipment_type_id = et.id + WHERE p.user_id = $1 + ORDER BY al.application_date DESC, al.created_at DESC + `, [req.user.id]); + + const logs = result.rows.map(row => ({ + id: row.id, + planId: row.plan_id, + propertyName: row.property_name, + propertyAddress: row.property_address, + sectionName: row.section_name, + equipmentName: row.equipment_name, + equipmentType: row.equipment_type, + applicationDate: row.application_date, + gpsTrack: row.gps_track, + averageSpeed: row.average_speed, + areaCovered: row.area_covered, + notes: row.notes, + createdAt: row.created_at + })); + + res.json({ + success: true, + data: { + logs + } + }); + } catch (error) { + next(error); + } +}); + module.exports = router; \ No newline at end of file diff --git a/frontend/src/components/Maps/PropertyMap.js b/frontend/src/components/Maps/PropertyMap.js index 6d47383..5887dcd 100644 --- a/frontend/src/components/Maps/PropertyMap.js +++ b/frontend/src/components/Maps/PropertyMap.js @@ -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(` + + + + + `), + iconSize: [20, 20], + iconAnchor: [10, 10], +}); + +const trackPointIcon = new Icon({ + iconUrl: 'data:image/svg+xml;base64,' + btoa(` + + + + `), + 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 && ( + [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 + + ) + ))} + + {/* Current Location */} + {currentLocation && ( + + )} + + )} {/* Current polygon being drawn */} {currentPolygon.length > 0 && ( @@ -440,8 +502,25 @@ const PropertyMap = ({ )} + {/* GPS Tracking stats */} + {mode === "execution" && gpsTrack.length > 0 && ( +
+

+ GPS Tracking Active +

+

+ Track Points: {gpsTrack.length} +

+ {currentLocation && ( +

+ Accuracy: ±{Math.round(currentLocation.accuracy || 0)}m +

+ )} +
+ )} + {/* Section stats */} - {sections.length > 0 && !isDrawing && ( + {sections.length > 0 && !isDrawing && mode !== "execution" && (

{sections.length} Section{sections.length !== 1 ? 's' : ''} diff --git a/frontend/src/pages/Applications/Applications.js b/frontend/src/pages/Applications/Applications.js index ca7e39c..c944fe3 100644 --- a/frontend/src/pages/Applications/Applications.js +++ b/frontend/src/pages/Applications/Applications.js @@ -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 (

@@ -364,6 +390,15 @@ const Applications = () => { Created {new Date(application.createdAt).toLocaleDateString()}

+ {application.status === 'planned' && ( + + )}
)} + + {/* Application Execution Modal */} + {showExecutionModal && executingApplication && ( + { + 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'); + } + }} + /> + )} +
+ + ); +}; + +// 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 ( +
+
+
+

Execute Application

+ +
+ + {/* Application Details */} +
+

Application Details

+
+
+ Property: {application.propertyName} +
+
+ Areas: {application.sectionNames} +
+
+ Equipment: {application.equipmentName} +
+
+ Area: {Math.round(application.totalSectionArea || application.sectionArea || 0).toLocaleString()} sq ft +
+
+ + {/* Products */} +
+
Products to Apply:
+
+ {application.products?.map((product, index) => ( +
+
{product.productName}
+
+ Rate: {product.rateAmount} {product.rateUnit} + {product.productAmount && ` • Product: ${product.productAmount}`} + {product.waterAmount && ` • Water: ${product.waterAmount}`} +
+
+ ))} +
+
+
+ + {/* Speed Guidance for Liquid Applications */} + {targetSpeed && ( +
+
+
+

Speed Guidance

+

+ Target: {targetSpeed.toFixed(1)} mph • Current: {currentSpeed.toFixed(1)} mph +

+
+
+ {speedStatus === 'normal' ? '✓ Good Speed' : + speedStatus === 'slow' ? '↑ Go Faster' : + '↓ Slow Down'} +
+
+
+ )} + + {/* Map with GPS Track */} +
+

Application Area & GPS Track

+
+ {propertyDetails && ( + s.id) || []} + mode="execution" + gpsTrack={gpsTrack} + currentLocation={currentLocation} + /> + )} +
+
+ + {/* Tracking Stats */} + {isTracking && ( +
+

Tracking Statistics

+
+
+ Duration:
+ {startTime ? Math.round((new Date() - startTime) / 60000) : 0} min +
+
+ Distance:
+ {(totalDistance * 3.28084).toFixed(0)} ft +
+
+ Avg Speed:
+ {averageSpeed.toFixed(1)} mph +
+
+ Track Points:
+ {gpsTrack.length} +
+
+
+ )} + + {/* Action Buttons */} +
+ {!isTracking ? ( + + ) : ( + <> + + + + )} + +
);