import React, { useState, useEffect } from 'react';
import {
PlusIcon,
MapPinIcon,
BeakerIcon,
WrenchScrewdriverIcon,
CalculatorIcon,
PencilIcon,
TrashIcon,
PlayIcon
} from '@heroicons/react/24/outline';
import { propertiesAPI, productsAPI, equipmentAPI, applicationsAPI, nozzlesAPI } from '../../services/api';
import LoadingSpinner from '../../components/UI/LoadingSpinner';
import PropertyMap from '../../components/Maps/PropertyMap';
import toast from 'react-hot-toast';
const Applications = () => {
const [showPlanForm, setShowPlanForm] = useState(false);
const [applications, setApplications] = useState([]);
const [loading, setLoading] = useState(true);
const [properties, setProperties] = useState([]);
const [products, setProducts] = useState([]);
const [equipment, setEquipment] = useState([]);
const [nozzles, setNozzles] = useState([]);
const [selectedPropertyDetails, setSelectedPropertyDetails] = useState(null);
const [editingPlan, setEditingPlan] = useState(null);
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);
useEffect(() => {
fetchApplications();
fetchPlanningData();
}, []);
const fetchApplications = async () => {
try {
const response = await applicationsAPI.getPlans();
setApplications(response.data.data.plans || []);
} catch (error) {
console.error('Failed to fetch applications:', error);
toast.error('Failed to load applications');
}
};
const fetchPlanningData = async () => {
try {
setLoading(true);
const [propertiesResponse, productsResponse, equipmentResponse, nozzlesResponse] = await Promise.all([
propertiesAPI.getAll(),
productsAPI.getAll(),
equipmentAPI.getAll(),
nozzlesAPI.getAll()
]);
setProperties(propertiesResponse.data.data.properties || []);
// Combine shared and user products with unique IDs
const sharedProducts = (productsResponse.data.data.sharedProducts || []).map(product => ({
...product,
uniqueId: `shared_${product.id}`,
isShared: true
}));
const userProducts = (productsResponse.data.data.userProducts || []).map(product => ({
...product,
uniqueId: `user_${product.id}`,
isShared: false
}));
const allProducts = [...sharedProducts, ...userProducts];
setProducts(allProducts);
setEquipment(equipmentResponse.data.data.equipment || []);
setNozzles(nozzlesResponse.data.data?.nozzles || nozzlesResponse.data || []);
} catch (error) {
console.error('Failed to fetch planning data:', error);
toast.error('Failed to load planning data');
} finally {
setLoading(false);
}
};
const fetchPropertyDetails = async (propertyId) => {
// Check cache first
if (propertyCache[propertyId]) {
setSelectedPropertyDetails(propertyCache[propertyId]);
return propertyCache[propertyId];
}
try {
const response = await propertiesAPI.getById(parseInt(propertyId));
const property = response.data.data.property;
// Cache the result
setPropertyCache(prev => ({ ...prev, [propertyId]: property }));
setSelectedPropertyDetails(property);
return property;
} catch (error) {
console.error('Failed to fetch property details:', error);
toast.error('Failed to load property details');
setSelectedPropertyDetails(null);
return null;
}
};
const handleDeletePlan = async (planId, planName) => {
if (window.confirm(`Are you sure you want to delete the plan for "${planName}"?`)) {
try {
await applicationsAPI.deletePlan(planId);
toast.success('Application plan deleted successfully');
fetchApplications(); // Refresh the list
} catch (error) {
console.error('Failed to delete plan:', error);
toast.error('Failed to delete application plan');
}
}
};
const handleEditPlan = async (planId) => {
try {
// Fetch the full plan details
const response = await applicationsAPI.getPlan(planId);
const plan = response.data.data.plan;
// Set up the editing plan data
setEditingPlan(plan);
setShowPlanForm(true);
} catch (error) {
console.error('Failed to fetch plan details:', error);
toast.error('Failed to load plan details');
}
};
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 (
);
}
// Load spreader recommendations when granular product and spreader are selected
const loadSpreaderRecommendation = async (product, equipmentId, selectedAreas) => {
if (!product || !equipmentId || !selectedAreas.length || product.productType !== 'granular') {
setSpreaderRecommendation(null);
return;
}
setLoadingRecommendation(true);
try {
// Find the selected equipment details
const selectedEquipment = equipment.find(eq => eq.id === parseInt(equipmentId));
if (!selectedEquipment) {
setSpreaderRecommendation(null);
return;
}
// Load spreader settings for this product
const productApiId = product.isShared ? product.id : product.id; // Use the actual product ID
const endpoint = product.isShared
? `/api/product-spreader-settings/product/${productApiId}`
: `/api/product-spreader-settings/user-product/${productApiId}`;
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (!response.ok) {
setSpreaderRecommendation(null);
return;
}
const data = await response.json();
const settings = data.data?.settings || [];
// Find a matching setting for this equipment
let matchingSetting = null;
// First try to find exact equipment match
matchingSetting = settings.find(setting =>
setting.equipmentId === selectedEquipment.id
);
// If no exact match, try to find by equipment brand/manufacturer
if (!matchingSetting && selectedEquipment.manufacturer) {
matchingSetting = settings.find(setting =>
setting.spreaderBrand &&
setting.spreaderBrand.toLowerCase().includes(selectedEquipment.manufacturer.toLowerCase())
);
}
// If still no match, use any available setting as fallback
if (!matchingSetting && settings.length > 0) {
matchingSetting = settings[0];
}
if (matchingSetting) {
// Calculate total area and product amount needed
const totalArea = selectedAreas.reduce((sum, areaId) => {
const area = selectedPropertyDetails?.sections?.find(s => s.id === areaId);
return sum + (area?.area || 0);
}, 0);
// Calculate product amount based on rate
const rateAmount = product.customRateAmount || product.rateAmount || 1;
const rateUnit = product.customRateUnit || product.rateUnit || 'lbs/1000 sq ft';
let productAmountLbs = 0;
if (rateUnit.includes('1000')) {
// Rate per 1000 sq ft
productAmountLbs = (rateAmount * totalArea) / 1000;
} else if (rateUnit.includes('acre')) {
// Rate per acre (43,560 sq ft)
productAmountLbs = (rateAmount * totalArea) / 43560;
} else {
// Assume rate per sq ft
productAmountLbs = rateAmount * totalArea;
}
setSpreaderRecommendation({
setting: matchingSetting,
equipment: selectedEquipment,
totalArea,
productAmountLbs: Math.round(productAmountLbs * 100) / 100, // Round to 2 decimal places
isExactMatch: settings.some(s => s.equipmentId === selectedEquipment.id)
});
} else {
setSpreaderRecommendation(null);
}
} catch (error) {
console.error('Failed to load spreader recommendation:', error);
setSpreaderRecommendation(null);
} finally {
setLoadingRecommendation(false);
}
};
return (
Applications
Plan, track, and log your lawn applications
{
setSelectedPropertyDetails(null); // Clear previous property data
setShowPlanForm(true);
}}
className="btn-primary flex items-center gap-2"
>
Plan Application
{/* Applications List */}
{applications.length === 0 ? (
No Applications Yet
Start by planning your first lawn application
{
setSelectedPropertyDetails(null); // Clear previous property data
setShowPlanForm(true);
}}
className="btn-primary"
>
Plan Your First Application
) : (
{applications.map((application) => (
{application.propertyName} - {application.sectionName}
{application.status}
{application.propertyAddress}
Areas: {application.sectionNames} ({Math.round(application.totalSectionArea || application.sectionArea || 0).toLocaleString()} sq ft)
Equipment: {application.equipmentName}
Products: {application.productCount}
{/* Display calculated amounts */}
{(application.totalProductAmount > 0 || (application.productDetails && application.productDetails.length > 0)) && (
Calculated Requirements:
{/* Show individual products for liquid tank mix */}
{application.productDetails && application.productDetails.length > 1 ? (
<>
{application.productDetails.map((product, index) => (
• {product.name}{product.brand ? ` (${product.brand})` : ''}: {product.calculatedAmount.toFixed(2)} oz
))}
{application.totalWaterAmount > 0 && (
• Water: {application.totalWaterAmount.toFixed(2)} gallons
)}
{application.avgSpeedMph > 0 && (
• Target Speed: {application.avgSpeedMph.toFixed(1)} mph
)}
{application.spreaderSetting && (
• Spreader Setting: {application.spreaderSetting}
)}
>
) : (
<>
{/* Show single product with name */}
{application.productDetails && application.productDetails.length === 1 ? (
• {application.productDetails[0].name}{application.productDetails[0].brand ? ` (${application.productDetails[0].brand})` : ''}: {application.productDetails[0].calculatedAmount.toFixed(2)} {application.totalWaterAmount > 0 ? 'oz' : 'lbs'}
) : (
• Product: {application.totalProductAmount.toFixed(2)} {application.totalWaterAmount > 0 ? 'oz' : 'lbs'}
)}
{application.totalWaterAmount > 0 && (
• Water: {application.totalWaterAmount.toFixed(2)} gallons
)}
{application.avgSpeedMph > 0 && (
• Target Speed: {application.avgSpeedMph.toFixed(1)} mph
)}
{application.spreaderSetting && (
• Spreader Setting: {application.spreaderSetting}
)}
>
)}
)}
{application.notes && (
"{application.notes}"
)}
{application.plannedDate ? new Date(application.plannedDate).toLocaleDateString() : 'No date set'}
Created {new Date(application.createdAt).toLocaleDateString()}
{application.status === 'planned' && (
handleExecuteApplication(application)}
className="p-1 text-green-600 hover:text-green-800 hover:bg-green-50 rounded"
title="Execute application"
>
)}
handleEditPlan(application.id)}
className="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded"
title="Edit plan"
>
handleDeletePlan(application.id, `${application.propertyName} - ${application.sectionName}`)}
className="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
title="Delete plan"
>
))}
)}
{/* Plan Application Modal */}
{showPlanForm && (
{
setShowPlanForm(false);
setEditingPlan(null);
setSelectedPropertyDetails(null);
}}
properties={properties}
products={products}
equipment={equipment}
nozzles={nozzles}
selectedPropertyDetails={selectedPropertyDetails}
onPropertySelect={fetchPropertyDetails}
editingPlan={editingPlan}
onSubmit={async (planData) => {
try {
if (editingPlan) {
// Edit existing plan - backend now supports multiple areas natively
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
// Calculate total area for all selected areas
const totalAreaSquareFeet = planData.selectedAreas.reduce((total, areaId) => {
const area = selectedPropertyDetails.sections.find(s => s.id === areaId);
return total + (area?.area || 0);
}, 0);
const planPayload = {
lawnSectionIds: planData.selectedAreas.map(id => parseInt(id)), // Send multiple areas
equipmentId: parseInt(planData.equipmentId),
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
plannedDate: planData.plannedDate || new Date().toISOString().split('T')[0],
notes: planData.notes || '',
areaSquareFeet: totalAreaSquareFeet,
equipment: {
id: selectedEquipment?.id,
categoryName: selectedEquipment?.categoryName,
tankSizeGallons: selectedEquipment?.tankSizeGallons,
pumpGpm: selectedEquipment?.pumpGpm,
sprayWidthFeet: selectedEquipment?.sprayWidthFeet,
capacityLbs: selectedEquipment?.capacityLbs,
spreadWidth: selectedEquipment?.spreadWidth
},
...(planData.applicationType === 'liquid' && selectedNozzle && {
nozzle: {
id: selectedNozzle.id,
flowRateGpm: selectedNozzle.flowRateGpm,
sprayAngle: selectedNozzle.sprayAngle
}
}),
products: planData.applicationType === 'liquid'
? planData.selectedProducts.map(item => ({
...(item.product?.isShared
? { productId: parseInt(item.product.id) }
: { userProductId: parseInt(item.product.id) }
),
rateAmount: parseFloat(item.rateAmount || 1),
rateUnit: item.rateUnit || 'oz/1000 sq ft',
applicationType: planData.applicationType
}))
: [{
...(planData.selectedProduct?.isShared
? { productId: parseInt(planData.selectedProduct.id) }
: { userProductId: parseInt(planData.selectedProduct.id) }
),
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft',
applicationType: planData.applicationType
}]
};
await applicationsAPI.updatePlan(editingPlan.id, planPayload);
toast.success(`Application plan updated successfully for ${planData.selectedAreas.length} area(s)`);
} else {
// Create new plan - backend now supports multiple areas in single plan
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
// Calculate total area for all selected areas
const totalAreaSquareFeet = planData.selectedAreas.reduce((total, areaId) => {
const area = selectedPropertyDetails.sections.find(s => s.id === areaId);
return total + (area?.area || 0);
}, 0);
const planPayload = {
lawnSectionIds: planData.selectedAreas.map(id => parseInt(id)), // Send multiple areas
equipmentId: parseInt(planData.equipmentId),
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
plannedDate: new Date().toISOString().split('T')[0],
notes: planData.notes || '',
areaSquareFeet: totalAreaSquareFeet,
equipment: {
id: selectedEquipment?.id,
categoryName: selectedEquipment?.categoryName,
tankSizeGallons: selectedEquipment?.tankSizeGallons,
pumpGpm: selectedEquipment?.pumpGpm,
sprayWidthFeet: selectedEquipment?.sprayWidthFeet,
capacityLbs: selectedEquipment?.capacityLbs,
spreadWidth: selectedEquipment?.spreadWidth
},
...(planData.applicationType === 'liquid' && selectedNozzle && {
nozzle: {
id: selectedNozzle.id,
flowRateGpm: selectedNozzle.flowRateGpm,
sprayAngle: selectedNozzle.sprayAngle
}
}),
products: planData.applicationType === 'liquid'
? planData.selectedProducts.map(item => ({
...(item.product?.isShared
? { productId: parseInt(item.product.id) }
: { userProductId: parseInt(item.product.id) }
),
rateAmount: parseFloat(item.rateAmount || 1),
rateUnit: item.rateUnit || 'oz/1000 sq ft',
applicationType: planData.applicationType
}))
: [{
...(planData.selectedProduct?.isShared
? { productId: parseInt(planData.selectedProduct.id) }
: { userProductId: parseInt(planData.selectedProduct.id) }
),
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft',
applicationType: planData.applicationType
}]
};
await applicationsAPI.createPlan(planPayload);
toast.success(`Created application plan for ${planData.selectedAreas.length} area(s) successfully`);
}
setShowPlanForm(false);
setEditingPlan(null);
fetchApplications();
} catch (error) {
console.error('Failed to save application plan:', error);
toast.error('Failed to save application plan');
}
}}
/>
)}
);
};
// Application Planning Modal Component
const ApplicationPlanModal = ({
onClose,
onSubmit,
properties,
products,
equipment,
nozzles,
selectedPropertyDetails,
onPropertySelect,
editingPlan
}) => {
const [loadingProperty, setLoadingProperty] = useState(false);
const [showSpreaderSettingPrompt, setShowSpreaderSettingPrompt] = useState(false);
const [missingSpreaderSetting, setMissingSpreaderSetting] = useState({
productId: null,
userProductId: null,
equipmentId: null,
productName: '',
equipmentName: ''
});
const [newSpreaderSetting, setNewSpreaderSetting] = useState({
settingValue: '',
rateDescription: '',
notes: ''
});
const [planData, setPlanData] = useState({
propertyId: '',
selectedAreas: [],
productId: '',
selectedProduct: null,
selectedProducts: [], // For liquid tank mixing - array of {product, rate}
applicationType: '', // 'liquid' or 'granular'
equipmentId: '',
nozzleId: '',
plannedDate: '',
notes: ''
});
// Reset form when modal opens fresh (not editing)
useEffect(() => {
if (!editingPlan) {
setPlanData({
propertyId: '',
selectedAreas: [],
productId: '',
selectedProduct: null,
selectedProducts: [],
applicationType: '',
equipmentId: '',
nozzleId: '',
plannedDate: '',
notes: ''
});
}
}, [editingPlan]);
// Check spreader settings when both product and equipment are selected for granular applications
useEffect(() => {
console.log('Spreader setting useEffect triggered:', {
applicationType: planData.applicationType,
hasSelectedProduct: !!planData.selectedProduct,
hasEquipmentId: !!planData.equipmentId,
productName: planData.selectedProduct?.customName || planData.selectedProduct?.name,
equipmentId: planData.equipmentId,
isEditing: !!editingPlan
});
if (planData.applicationType === 'granular' &&
planData.selectedProduct &&
planData.equipmentId) {
console.log('Triggering spreader setting check from useEffect');
checkSpreaderSetting(planData.selectedProduct, planData.equipmentId);
}
}, [planData.applicationType, planData.selectedProduct, planData.equipmentId]);
// Initialize form with editing data
useEffect(() => {
if (editingPlan && products.length > 0) {
const propertyId = editingPlan.section?.propertyId || editingPlan.property?.id;
// Determine application type from the first product
const firstProduct = editingPlan.products?.[0];
let applicationType = 'liquid'; // Default to liquid
if (firstProduct) {
const productType = firstProduct.productType || firstProduct.customProductType;
// Use flexible matching like elsewhere in the codebase
const isGranular = productType && (
productType.toLowerCase().includes('granular') ||
productType.toLowerCase().includes('granule')
);
// Also check equipment type as fallback for granular detection
const equipmentType = editingPlan.equipment?.categoryName?.toLowerCase() || '';
const isGranularByEquipment = equipmentType.includes('spreader') || equipmentType.includes('granular');
// If we can't determine from product type, use equipment type
applicationType = (isGranular || (!productType && isGranularByEquipment)) ? 'granular' : 'liquid';
console.log('Edit plan - application type detection:', {
productType: productType,
isGranular: isGranular,
equipmentType: equipmentType,
isGranularByEquipment: isGranularByEquipment,
applicationType: applicationType,
productName: firstProduct.productName,
fullProduct: firstProduct
});
}
// Handle different application types
if (applicationType === 'granular') {
// Granular - single product
let selectedProduct = null;
if (firstProduct) {
if (firstProduct.productId) {
selectedProduct = products.find(p => p.uniqueId === `shared_${firstProduct.productId}`);
} else if (firstProduct.userProductId) {
selectedProduct = products.find(p => p.uniqueId === `user_${firstProduct.userProductId}`);
}
}
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: editingPlan.sections?.map(s => s.id) || [], // Handle multiple areas
productId: selectedProduct?.uniqueId || '',
selectedProduct: selectedProduct,
selectedProducts: [],
applicationType: 'granular',
equipmentId: editingPlan.equipment?.id?.toString() || '',
nozzleId: editingPlan.nozzle?.id?.toString() || '',
plannedDate: editingPlan.plannedDate ? new Date(editingPlan.plannedDate).toISOString().split('T')[0] : '',
notes: editingPlan.notes || ''
});
} else {
// Liquid - multiple products (tank mix)
const selectedProducts = editingPlan.products?.map(product => {
let foundProduct = null;
if (product.productId) {
foundProduct = products.find(p => p.uniqueId === `shared_${product.productId}`);
} else if (product.userProductId) {
foundProduct = products.find(p => p.uniqueId === `user_${product.userProductId}`);
}
return {
product: foundProduct,
rateAmount: product.rateAmount || 1,
rateUnit: product.rateUnit || 'oz/1000 sq ft'
};
}).filter(item => item.product) || [];
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: editingPlan.sections?.map(s => s.id) || [], // Handle multiple areas
productId: '',
selectedProduct: null,
selectedProducts: selectedProducts,
applicationType: 'liquid',
equipmentId: editingPlan.equipment?.id?.toString() || '',
nozzleId: editingPlan.nozzle?.id?.toString() || '',
plannedDate: editingPlan.plannedDate ? new Date(editingPlan.plannedDate).toISOString().split('T')[0] : '',
notes: editingPlan.notes || ''
});
}
// Only fetch property details if we don't already have them
if (propertyId && (!selectedPropertyDetails || selectedPropertyDetails.id !== propertyId)) {
onPropertySelect(propertyId);
}
}
}, [editingPlan, products]);
const handlePropertyChange = async (propertyId) => {
setPlanData({ ...planData, propertyId, selectedAreas: [] });
if (propertyId && propertyId !== selectedPropertyDetails?.id?.toString()) {
setLoadingProperty(true);
await onPropertySelect(propertyId);
setLoadingProperty(false);
}
};
// Check for spreader settings when granular product + equipment selected
const checkSpreaderSetting = async (product, equipmentId) => {
if (!product || !equipmentId) {
return;
}
// Check if product is granular (more flexible matching like the product filter)
const productType = product.productType || product.customProductType;
const isGranular = productType && (
productType.toLowerCase().includes('granular') ||
productType.toLowerCase().includes('granule')
);
if (!isGranular) {
return;
}
const selectedEquipment = equipment.find(eq => eq.id === parseInt(equipmentId));
if (!selectedEquipment) {
console.log('Spreader setting check skipped - equipment not found:', {
equipmentId: equipmentId
});
return;
}
// Check if equipment is spreader-related (more flexible matching)
const categoryName = selectedEquipment.categoryName?.toLowerCase() || '';
const isSpreaderEquipment = categoryName.includes('spreader') || categoryName.includes('granular');
if (!isSpreaderEquipment) {
console.log('Spreader setting check skipped - equipment not a spreader:', {
selectedEquipment: selectedEquipment?.customName,
category: selectedEquipment?.categoryName
});
return;
}
console.log('Checking spreader setting for:', {
product: product.customName || product.name,
equipment: selectedEquipment.customName,
productType: productType
});
try {
// Check if spreader setting exists
const endpoint = product.isShared
? `/api/product-spreader-settings/product/${product.productId || product.id}`
: `/api/product-spreader-settings/user-product/${product.id}`;
const response = await fetch(endpoint, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
}
});
if (response.ok) {
const data = await response.json();
const settings = data.data?.settings || [];
// Check if there's a setting for this specific equipment
const existingSetting = settings.find(setting =>
setting.equipmentId === selectedEquipment.id
);
if (!existingSetting) {
// No setting found - prompt user to create one
setMissingSpreaderSetting({
productId: product.isShared ? (product.productId || product.id) : null,
userProductId: product.isShared ? null : product.id,
equipmentId: selectedEquipment.id,
productName: product.customName || product.name,
equipmentName: selectedEquipment.customName
});
setShowSpreaderSettingPrompt(true);
}
}
} catch (error) {
console.error('Failed to check spreader settings:', error);
}
};
// Save new spreader setting
const saveSpreaderSetting = async () => {
try {
const settingData = {
...(missingSpreaderSetting.productId && { productId: missingSpreaderSetting.productId }),
...(missingSpreaderSetting.userProductId && { userProductId: missingSpreaderSetting.userProductId }),
equipmentId: missingSpreaderSetting.equipmentId,
settingValue: newSpreaderSetting.settingValue,
rateDescription: newSpreaderSetting.rateDescription || null,
notes: newSpreaderSetting.notes || null
};
const response = await fetch('/api/product-spreader-settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
},
body: JSON.stringify(settingData)
});
if (response.ok) {
toast.success('Spreader setting saved successfully');
setShowSpreaderSettingPrompt(false);
setNewSpreaderSetting({ settingValue: '', rateDescription: '', notes: '' });
} else {
toast.error('Failed to save spreader setting');
}
} catch (error) {
console.error('Failed to save spreader setting:', error);
toast.error('Failed to save spreader setting');
}
};
// Filter equipment based on application type
const availableEquipment = equipment.filter(eq => {
if (planData.applicationType === 'liquid') {
return eq.categoryName === 'Sprayer';
} else if (planData.applicationType === 'granular') {
return eq.categoryName === 'Spreader';
}
return false;
});
const handleSubmit = (e) => {
e.preventDefault();
if (!planData.propertyId || planData.selectedAreas.length === 0) {
toast.error('Please select a property and at least one area');
return;
}
if (!planData.applicationType) {
toast.error('Please select an application type');
return;
}
// Validate product selection based on application type
if (planData.applicationType === 'granular') {
if (!planData.productId) {
toast.error('Please select a product');
return;
}
} else if (planData.applicationType === 'liquid') {
if (planData.selectedProducts.length === 0) {
toast.error('Please select at least one product for tank mixing');
return;
}
// Validate that all selected products have rates
const missingRates = planData.selectedProducts.filter(p => !p.rateAmount || p.rateAmount <= 0);
if (missingRates.length > 0) {
toast.error('Please enter application rates for all selected products');
return;
}
}
if (!planData.equipmentId) {
toast.error('Please select equipment');
return;
}
onSubmit(planData);
};
const handleAreaToggle = (areaId) => {
setPlanData(prev => ({
...prev,
selectedAreas: prev.selectedAreas.includes(areaId)
? prev.selectedAreas.filter(id => id !== areaId)
: [...prev.selectedAreas, areaId]
}));
};
return (
{editingPlan ? 'Edit Application Plan' : 'Plan Application'}
{/* Spreader Setting Prompt Modal */}
{showSpreaderSettingPrompt && (
Spreader Setting Required
No spreader setting found for {missingSpreaderSetting.productName} with {missingSpreaderSetting.equipmentName} .
Please enter the spreader setting to continue.
Save Setting
{
setShowSpreaderSettingPrompt(false);
setNewSpreaderSetting({ settingValue: '', rateDescription: '', notes: '' });
}}
className="btn-secondary flex-1"
>
Cancel
)}
{/* Application Execution Modal */}
{showExecutionModal && executingApplication && (
{
setShowExecutionModal(false);
setExecutingApplication(null);
}}
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);
} 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 ? (
Start Application
) : (
<>
Pause Tracking
Complete Application
>
)}
Cancel
);
};
export default Applications;