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
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
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()}
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]);
// 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];
const applicationType = firstProduct?.productType === 'granular' ? 'granular' : 'liquid';
// 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 ||
(product.productType !== 'granular' && product.customProductType !== 'granular')) {
return;
}
const selectedEquipment = equipment.find(eq => eq.id === parseInt(equipmentId));
if (!selectedEquipment || selectedEquipment.categoryName !== 'Spreader') {
return;
}
try {
// Check if spreader setting exists
const productApiId = product.isShared ? product.id : 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) {
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.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
)}
);
};
export default Applications;