perform applications
This commit is contained in:
@@ -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;
|
module.exports = router;
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback, useRef } from 'react';
|
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 { Icon } from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
@@ -11,6 +11,28 @@ Icon.Default.mergeOptions({
|
|||||||
shadowUrl: require('leaflet/dist/images/marker-shadow.png'),
|
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
|
// Custom component to handle map clicks for drawing polygons
|
||||||
const DrawingHandler = ({ isDrawing, onPointAdd, onDrawingComplete }) => {
|
const DrawingHandler = ({ isDrawing, onPointAdd, onDrawingComplete }) => {
|
||||||
useMapEvents({
|
useMapEvents({
|
||||||
@@ -64,7 +86,12 @@ const PropertyMap = ({
|
|||||||
onSectionClick,
|
onSectionClick,
|
||||||
selectedSections = [],
|
selectedSections = [],
|
||||||
editable = false,
|
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 [isDrawing, setIsDrawing] = useState(false);
|
||||||
const [currentPolygon, setCurrentPolygon] = useState([]);
|
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 */}
|
{/* Current polygon being drawn */}
|
||||||
{currentPolygon.length > 0 && (
|
{currentPolygon.length > 0 && (
|
||||||
@@ -440,8 +502,25 @@ const PropertyMap = ({
|
|||||||
</div>
|
</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 */}
|
{/* 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">
|
<div className="absolute bottom-4 left-4 bg-white rounded-lg shadow-lg p-3">
|
||||||
<p className="text-sm font-medium text-gray-700">
|
<p className="text-sm font-medium text-gray-700">
|
||||||
{sections.length} Section{sections.length !== 1 ? 's' : ''}
|
{sections.length} Section{sections.length !== 1 ? 's' : ''}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import {
|
|||||||
WrenchScrewdriverIcon,
|
WrenchScrewdriverIcon,
|
||||||
CalculatorIcon,
|
CalculatorIcon,
|
||||||
PencilIcon,
|
PencilIcon,
|
||||||
TrashIcon
|
TrashIcon,
|
||||||
|
PlayIcon
|
||||||
} from '@heroicons/react/24/outline';
|
} from '@heroicons/react/24/outline';
|
||||||
import { propertiesAPI, productsAPI, equipmentAPI, applicationsAPI, nozzlesAPI } from '../../services/api';
|
import { propertiesAPI, productsAPI, equipmentAPI, applicationsAPI, nozzlesAPI } from '../../services/api';
|
||||||
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
||||||
@@ -26,6 +27,14 @@ const Applications = () => {
|
|||||||
const [propertyCache, setPropertyCache] = useState({});
|
const [propertyCache, setPropertyCache] = useState({});
|
||||||
const [spreaderRecommendation, setSpreaderRecommendation] = useState(null);
|
const [spreaderRecommendation, setSpreaderRecommendation] = useState(null);
|
||||||
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchApplications();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -364,6 +390,15 @@ const Applications = () => {
|
|||||||
Created {new Date(application.createdAt).toLocaleDateString()}
|
Created {new Date(application.createdAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 mt-2">
|
<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
|
<button
|
||||||
onClick={() => handleEditPlan(application.id)}
|
onClick={() => handleEditPlan(application.id)}
|
||||||
className="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded"
|
className="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded"
|
||||||
@@ -1366,6 +1401,367 @@ const ApplicationPlanModal = ({
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user