import React, { useState, useEffect } from 'react';
import {
PlusIcon,
MapPinIcon,
BeakerIcon,
WrenchScrewdriverIcon,
CalculatorIcon,
PencilIcon,
TrashIcon
} 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);
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');
}
};
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
{/* Applications List */}
{applications.length === 0 ? (
No Applications Yet
Start by planning your first lawn application
) : (
{applications.map((application) => (
{application.propertyName} - {application.sectionName}
{application.status}
{application.propertyAddress}
Area: {Math.round(application.sectionArea).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()}
))}
)}
{/* Plan Application Modal */}
{showPlanForm && (
{
setShowPlanForm(false);
setEditingPlan(null);
}}
properties={properties}
products={products}
equipment={equipment}
nozzles={nozzles}
selectedPropertyDetails={selectedPropertyDetails}
onPropertySelect={fetchPropertyDetails}
editingPlan={editingPlan}
onSubmit={async (planData) => {
try {
if (editingPlan) {
// Edit existing plan
const selectedArea = selectedPropertyDetails.sections.find(s => s.id === planData.selectedAreas[0]);
const areaSquareFeet = selectedArea?.area || 0;
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
const planPayload = {
lawnSectionId: parseInt(planData.selectedAreas[0]),
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: areaSquareFeet,
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');
} else {
// Create new plan(s)
const planPromises = planData.selectedAreas.map(async (areaId) => {
const selectedArea = selectedPropertyDetails.sections.find(s => s.id === areaId);
const areaSquareFeet = selectedArea?.area || 0;
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
const planPayload = {
lawnSectionId: parseInt(areaId),
equipmentId: parseInt(planData.equipmentId),
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
plannedDate: new Date().toISOString().split('T')[0],
notes: planData.notes || '',
areaSquareFeet: areaSquareFeet,
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
}]
};
return applicationsAPI.createPlan(planPayload);
});
await Promise.all(planPromises);
toast.success(`Created ${planData.selectedAreas.length} application plan(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 [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: ''
});
// Initialize form with editing data
useEffect(() => {
if (editingPlan && products.length > 0) {
const propertyId = editingPlan.section?.propertyId || editingPlan.property?.id;
// Find the product from the plans products array
const planProduct = editingPlan.products?.[0];
let selectedProduct = null;
if (planProduct) {
if (planProduct.productId) {
selectedProduct = products.find(p => p.uniqueId === `shared_${planProduct.productId}`);
} else if (planProduct.userProductId) {
selectedProduct = products.find(p => p.uniqueId === `user_${planProduct.userProductId}`);
}
}
setPlanData({
propertyId: propertyId?.toString() || '',
selectedAreas: [editingPlan.section?.id],
productId: selectedProduct?.uniqueId || '',
selectedProduct: selectedProduct,
applicationType: planProduct?.applicationType || '',
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);
}
};
// 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'}
);
};
export default Applications;