990 lines
39 KiB
JavaScript
990 lines
39 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import { XMarkIcon, MapPinIcon, WrenchScrewdriverIcon, BeakerIcon } from '@heroicons/react/24/outline';
|
|
import PropertyMap from '../Maps/PropertyMap';
|
|
import toast from 'react-hot-toast';
|
|
|
|
const ApplicationPlanModal = ({
|
|
onClose,
|
|
properties,
|
|
products,
|
|
equipment,
|
|
nozzles,
|
|
selectedPropertyDetails,
|
|
onPropertySelect,
|
|
editingPlan,
|
|
onSubmit
|
|
}) => {
|
|
const [selectedPropertyId, setSelectedPropertyId] = useState('');
|
|
const [selectedAreas, setSelectedAreas] = useState([]);
|
|
const [selectedEquipmentId, setSelectedEquipmentId] = useState('');
|
|
const [selectedNozzleId, setSelectedNozzleId] = useState('');
|
|
const [selectedProducts, setSelectedProducts] = useState([]);
|
|
const [applicationType, setApplicationType] = useState('');
|
|
const [plannedDate, setPlannedDate] = useState('');
|
|
const [notes, setNotes] = useState('');
|
|
const [loading, setLoading] = useState(false);
|
|
const [spreaderSettings, setSpreaderSettings] = useState({});
|
|
const [showSpreaderSettingsForm, setShowSpreaderSettingsForm] = useState(false);
|
|
const [currentProductForSettings, setCurrentProductForSettings] = useState(null);
|
|
const [spreaderFormData, setSpreaderFormData] = useState({
|
|
setting: '',
|
|
rateDescription: '',
|
|
notes: ''
|
|
});
|
|
|
|
// Calculate map center from property or sections
|
|
const mapCenter = React.useMemo(() => {
|
|
// First try to use property coordinates
|
|
if (selectedPropertyDetails?.latitude && selectedPropertyDetails?.longitude) {
|
|
return [selectedPropertyDetails.latitude, selectedPropertyDetails.longitude];
|
|
}
|
|
|
|
// Fall back to calculating center from sections
|
|
if (selectedPropertyDetails?.sections?.length > 0) {
|
|
let totalLat = 0;
|
|
let totalLng = 0;
|
|
let pointCount = 0;
|
|
|
|
selectedPropertyDetails.sections.forEach(section => {
|
|
let polygonData = section.polygonData;
|
|
if (typeof polygonData === 'string') {
|
|
try {
|
|
polygonData = JSON.parse(polygonData);
|
|
} catch (e) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (polygonData?.coordinates?.[0]) {
|
|
polygonData.coordinates[0].forEach(([lat, lng]) => {
|
|
totalLat += lat;
|
|
totalLng += lng;
|
|
pointCount++;
|
|
});
|
|
}
|
|
});
|
|
|
|
if (pointCount > 0) {
|
|
return [totalLat / pointCount, totalLng / pointCount];
|
|
}
|
|
}
|
|
|
|
// Default center
|
|
return [39.8283, -98.5795];
|
|
}, [selectedPropertyDetails]);
|
|
|
|
// Initialize form with editing data if provided
|
|
useEffect(() => {
|
|
if (editingPlan) {
|
|
setSelectedPropertyId(editingPlan.propertyId || '');
|
|
setSelectedAreas(editingPlan.selectedAreas || []);
|
|
setSelectedEquipmentId(editingPlan.equipmentId || '');
|
|
setSelectedNozzleId(editingPlan.nozzleId || '');
|
|
setSelectedProducts(editingPlan.products || []);
|
|
setPlannedDate(editingPlan.plannedDate || '');
|
|
setNotes(editingPlan.notes || '');
|
|
}
|
|
}, [editingPlan]);
|
|
|
|
// Handle property selection
|
|
const handlePropertyChange = async (propertyId) => {
|
|
setSelectedPropertyId(propertyId);
|
|
setSelectedAreas([]); // Clear selected areas when property changes
|
|
|
|
if (propertyId && onPropertySelect) {
|
|
await onPropertySelect(propertyId);
|
|
}
|
|
};
|
|
|
|
// Debug logging
|
|
React.useEffect(() => {
|
|
console.log('=== ApplicationPlanModal Debug ===');
|
|
console.log('selectedPropertyDetails:', selectedPropertyDetails);
|
|
console.log('mapCenter:', mapCenter);
|
|
console.log('sections:', selectedPropertyDetails?.sections);
|
|
|
|
console.log('\n=== EQUIPMENT DEBUG ===');
|
|
console.log('equipment array length:', equipment?.length);
|
|
console.log('equipment full array:', equipment);
|
|
if (equipment && equipment.length > 0) {
|
|
console.log('first equipment item:', equipment[0]);
|
|
console.log('equipment field analysis:');
|
|
equipment.forEach((eq, i) => {
|
|
console.log(`Equipment ${i}:`, {
|
|
id: eq.id,
|
|
name: eq.name,
|
|
equipment_name: eq.equipment_name,
|
|
custom_name: eq.custom_name,
|
|
category_name: eq.category_name,
|
|
type_name: eq.type_name,
|
|
category: eq.category,
|
|
type: eq.type,
|
|
allFields: Object.keys(eq)
|
|
});
|
|
});
|
|
}
|
|
|
|
console.log('\n=== PRODUCTS DEBUG ===');
|
|
console.log('products array length:', products?.length);
|
|
console.log('products full array:', products);
|
|
if (products && products.length > 0) {
|
|
console.log('first product item:', products[0]);
|
|
console.log('product field analysis:');
|
|
products.forEach((prod, i) => {
|
|
if (i < 3) { // Only show first 3 to avoid spam
|
|
console.log(`Product ${i}:`, {
|
|
id: prod.id,
|
|
name: prod.name,
|
|
product_name: prod.product_name,
|
|
productName: prod.productName,
|
|
brand: prod.brand,
|
|
product_brand: prod.product_brand,
|
|
productBrand: prod.productBrand,
|
|
productType: prod.productType,
|
|
product_type: prod.product_type,
|
|
type: prod.type,
|
|
isShared: prod.isShared,
|
|
uniqueId: prod.uniqueId,
|
|
allFields: Object.keys(prod)
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
console.log('\n=== NOZZLES DEBUG ===');
|
|
console.log('nozzles array length:', nozzles?.length);
|
|
console.log('nozzles full array:', nozzles);
|
|
if (nozzles && nozzles.length > 0) {
|
|
console.log('first nozzle item:', nozzles[0]);
|
|
console.log('nozzle field analysis:');
|
|
nozzles.forEach((nozzle, i) => {
|
|
console.log(`Nozzle ${i}:`, {
|
|
id: nozzle.id,
|
|
name: nozzle.name,
|
|
custom_name: nozzle.custom_name,
|
|
manufacturer: nozzle.manufacturer,
|
|
flow_rate_gpm: nozzle.flow_rate_gpm,
|
|
orifice_size: nozzle.orifice_size,
|
|
allFields: Object.keys(nozzle)
|
|
});
|
|
});
|
|
}
|
|
console.log('=== End Debug ===\n');
|
|
}, [selectedPropertyDetails, mapCenter, equipment, products, nozzles]);
|
|
|
|
// Handle area selection on map
|
|
const handleAreaClick = (area) => {
|
|
setSelectedAreas(prev => {
|
|
if (prev.includes(area.id)) {
|
|
return prev.filter(id => id !== area.id);
|
|
} else {
|
|
return [...prev, area.id];
|
|
}
|
|
});
|
|
};
|
|
|
|
// Add product to plan (for liquid tank mixes)
|
|
const addProduct = () => {
|
|
setSelectedProducts(prev => [...prev, {
|
|
uniqueId: '',
|
|
productId: null,
|
|
userProductId: null,
|
|
productName: '',
|
|
productBrand: '',
|
|
productType: applicationType,
|
|
rateAmount: '',
|
|
rateUnit: applicationType === 'granular' ? 'lb/1000sqft' : 'oz/1000sqft',
|
|
isUserProduct: false
|
|
}]);
|
|
};
|
|
|
|
// Handle application type change
|
|
const handleApplicationTypeChange = (type) => {
|
|
setApplicationType(type);
|
|
// Clear products when switching type
|
|
setSelectedProducts([]);
|
|
// Add initial product
|
|
setTimeout(() => {
|
|
setSelectedProducts([{
|
|
uniqueId: '',
|
|
productId: null,
|
|
userProductId: null,
|
|
productName: '',
|
|
productBrand: '',
|
|
productType: type,
|
|
rateAmount: '',
|
|
rateUnit: type === 'granular' ? 'lb/1000sqft' : 'oz/1000sqft',
|
|
isUserProduct: false
|
|
}]);
|
|
}, 0);
|
|
};
|
|
|
|
// Remove product from plan
|
|
const removeProduct = (index) => {
|
|
setSelectedProducts(prev => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
// Update product in plan
|
|
const updateProduct = (index, field, value) => {
|
|
setSelectedProducts(prev => prev.map((product, i) =>
|
|
i === index ? { ...product, [field]: value } : product
|
|
));
|
|
};
|
|
|
|
// Check spreader settings for granular products
|
|
const checkSpreaderSettings = async (product, equipmentId) => {
|
|
try {
|
|
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}`;
|
|
|
|
console.log('Checking spreader settings:', { product, equipmentId, endpoint });
|
|
|
|
const response = await fetch(endpoint, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('Spreader settings response:', data);
|
|
const settings = data.data?.settings || [];
|
|
|
|
// For shared products, check if any setting has the matching equipmentId
|
|
// For user products, the query already filters by equipment
|
|
const equipmentSetting = settings.find(s => {
|
|
console.log('Comparing setting:', s, 'with equipmentId:', equipmentId);
|
|
return s.equipmentId === parseInt(equipmentId);
|
|
});
|
|
|
|
console.log('Found equipment setting:', equipmentSetting);
|
|
|
|
if (!equipmentSetting) {
|
|
// No spreader setting found, prompt user to add one
|
|
console.log('No setting found, showing form');
|
|
setCurrentProductForSettings(product);
|
|
setShowSpreaderSettingsForm(true);
|
|
} else {
|
|
// Store the spreader setting for calculations
|
|
console.log('Setting found, storing for calculations');
|
|
setSpreaderSettings(prev => ({
|
|
...prev,
|
|
[`${product.id}_${equipmentId}`]: equipmentSetting
|
|
}));
|
|
}
|
|
} else {
|
|
console.log('Response not ok:', response.status);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to check spreader settings:', error);
|
|
}
|
|
};
|
|
|
|
// Save spreader settings
|
|
const saveSpreaderSettings = async () => {
|
|
try {
|
|
if (!spreaderFormData.setting) {
|
|
toast.error('Please enter a setting value');
|
|
return;
|
|
}
|
|
|
|
// Build payload with correct field names and handle xor validation
|
|
const payload = {
|
|
equipmentId: parseInt(selectedEquipmentId),
|
|
settingValue: spreaderFormData.setting.toString(), // Must be string according to schema
|
|
rateDescription: spreaderFormData.rateDescription || null,
|
|
notes: spreaderFormData.notes || null
|
|
};
|
|
|
|
// Add either productId OR userProductId, not both (xor validation)
|
|
if (currentProductForSettings.isShared) {
|
|
payload.productId = currentProductForSettings.id;
|
|
} else {
|
|
payload.userProductId = currentProductForSettings.id;
|
|
}
|
|
|
|
const response = await fetch('/api/product-spreader-settings', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
|
},
|
|
body: JSON.stringify(payload)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const result = await response.json();
|
|
console.log('Spreader setting saved successfully:', result);
|
|
|
|
toast.success('Spreader setting added successfully');
|
|
|
|
// Store the new setting for calculations
|
|
if (result.data?.setting) {
|
|
setSpreaderSettings(prev => ({
|
|
...prev,
|
|
[`${currentProductForSettings.id}_${selectedEquipmentId}`]: result.data.setting
|
|
}));
|
|
}
|
|
|
|
// Close the form
|
|
setShowSpreaderSettingsForm(false);
|
|
setCurrentProductForSettings(null);
|
|
setSpreaderFormData({
|
|
setting: '',
|
|
rateDescription: '',
|
|
notes: ''
|
|
});
|
|
} else {
|
|
const errorData = await response.json();
|
|
console.error('Failed to save spreader settings:', errorData);
|
|
throw new Error(errorData.message || 'Failed to save spreader settings');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to save spreader settings:', error);
|
|
toast.error('Failed to save spreader settings');
|
|
}
|
|
};
|
|
|
|
// Calculate application amounts
|
|
const calculateApplicationAmounts = () => {
|
|
if (!selectedPropertyDetails?.sections || selectedAreas.length === 0) return null;
|
|
|
|
const totalArea = selectedAreas.reduce((total, areaId) => {
|
|
const area = selectedPropertyDetails.sections.find(s => s.id === areaId);
|
|
return total + (area?.area || 0);
|
|
}, 0);
|
|
|
|
const calculations = selectedProducts.map(product => {
|
|
const rateAmount = parseFloat(product.rateAmount) || 0;
|
|
const areaIn1000s = totalArea / 1000;
|
|
|
|
if (product.productType === 'granular') {
|
|
// Granular calculations
|
|
const totalProductNeeded = rateAmount * areaIn1000s;
|
|
return {
|
|
productName: product.productName,
|
|
totalProductNeeded: totalProductNeeded.toFixed(2),
|
|
unit: product.rateUnit,
|
|
waterNeeded: 0 // No water for granular
|
|
};
|
|
} else {
|
|
// Liquid calculations
|
|
const totalProductNeeded = rateAmount * areaIn1000s;
|
|
|
|
// Calculate water needed based on nozzle and equipment settings
|
|
let waterNeeded = 0;
|
|
if (selectedNozzleId) {
|
|
const selectedNozzle = nozzles.find(n => n.id === parseInt(selectedNozzleId));
|
|
if (selectedNozzle && selectedNozzle.flowRateGpm) {
|
|
// Basic calculation - this would need equipment speed settings for accuracy
|
|
waterNeeded = (selectedNozzle.flowRateGpm * 60 * areaIn1000s) / 1000; // Rough estimate
|
|
}
|
|
}
|
|
|
|
return {
|
|
productName: product.productName,
|
|
totalProductNeeded: totalProductNeeded.toFixed(2),
|
|
unit: product.rateUnit,
|
|
waterNeeded: waterNeeded.toFixed(1)
|
|
};
|
|
}
|
|
});
|
|
|
|
return {
|
|
totalArea: totalArea.toLocaleString(),
|
|
calculations
|
|
};
|
|
};
|
|
|
|
// Handle form submission
|
|
const handleSubmit = async (e) => {
|
|
e.preventDefault();
|
|
|
|
// Validation
|
|
if (!selectedPropertyId) {
|
|
toast.error('Please select a property');
|
|
return;
|
|
}
|
|
if (selectedAreas.length === 0) {
|
|
toast.error('Please select at least one area');
|
|
return;
|
|
}
|
|
if (!selectedEquipmentId) {
|
|
toast.error('Please select equipment');
|
|
return;
|
|
}
|
|
if (selectedProducts.length === 0) {
|
|
toast.error('Please add at least one product');
|
|
return;
|
|
}
|
|
if (!plannedDate) {
|
|
toast.error('Please select a planned date');
|
|
return;
|
|
}
|
|
if (applicationType === 'liquid' && !selectedNozzleId) {
|
|
toast.error('Please select a nozzle for liquid applications');
|
|
return;
|
|
}
|
|
|
|
// Validate products have required fields
|
|
for (const product of selectedProducts) {
|
|
if (!product.productId && !product.userProductId) {
|
|
toast.error('Please select a product for all entries');
|
|
return;
|
|
}
|
|
if (!product.rateAmount) {
|
|
toast.error('Please enter rate amount for all products');
|
|
return;
|
|
}
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const planData = {
|
|
propertyId: parseInt(selectedPropertyId),
|
|
selectedAreas,
|
|
equipmentId: selectedEquipmentId,
|
|
nozzleId: selectedNozzleId || null,
|
|
products: selectedProducts,
|
|
plannedDate,
|
|
notes
|
|
};
|
|
|
|
await onSubmit(planData);
|
|
onClose();
|
|
} catch (error) {
|
|
console.error('Failed to submit plan:', error);
|
|
toast.error('Failed to save application plan');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
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-6xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex justify-between items-center mb-6">
|
|
<h2 className="text-2xl font-bold text-gray-900">
|
|
{editingPlan ? 'Edit Application Plan' : 'Plan New Application'}
|
|
</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600"
|
|
>
|
|
<XMarkIcon className="h-6 w-6" />
|
|
</button>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Left Column - Form */}
|
|
<div className="space-y-6">
|
|
{/* Property Selection */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
<MapPinIcon className="h-4 w-4 inline mr-1" />
|
|
Property
|
|
</label>
|
|
<select
|
|
value={selectedPropertyId}
|
|
onChange={(e) => handlePropertyChange(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
required
|
|
>
|
|
<option value="">Select a property</option>
|
|
{properties.map(property => (
|
|
<option key={property.id} value={property.id}>
|
|
{property.name} - {property.address}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
|
|
{/* Planned Date */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Planned Date
|
|
</label>
|
|
<input
|
|
type="date"
|
|
value={plannedDate}
|
|
onChange={(e) => setPlannedDate(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Application Type & Equipment */}
|
|
<div className="border rounded-lg p-4 bg-gray-50">
|
|
<div className="grid grid-cols-1 gap-4">
|
|
{/* Application Type */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
<BeakerIcon className="h-4 w-4 inline mr-1" />
|
|
Application Type
|
|
</label>
|
|
<div className="flex gap-4">
|
|
<label className="flex items-center">
|
|
<input
|
|
type="radio"
|
|
name="applicationType"
|
|
value="granular"
|
|
checked={applicationType === 'granular'}
|
|
onChange={(e) => handleApplicationTypeChange(e.target.value)}
|
|
className="mr-2"
|
|
/>
|
|
Granular (Spreader)
|
|
</label>
|
|
<label className="flex items-center">
|
|
<input
|
|
type="radio"
|
|
name="applicationType"
|
|
value="liquid"
|
|
checked={applicationType === 'liquid'}
|
|
onChange={(e) => handleApplicationTypeChange(e.target.value)}
|
|
className="mr-2"
|
|
/>
|
|
Liquid (Sprayer)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Equipment Selection - Required for all */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
<WrenchScrewdriverIcon className="h-4 w-4 inline mr-1" />
|
|
Equipment *
|
|
</label>
|
|
<select
|
|
value={selectedEquipmentId}
|
|
onChange={(e) => setSelectedEquipmentId(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
required
|
|
>
|
|
<option value="">Select equipment</option>
|
|
{equipment
|
|
.filter(eq => {
|
|
if (!applicationType) return true;
|
|
// Filter by application type - granular needs Spreader, liquid needs Sprayer
|
|
if (applicationType === 'granular') {
|
|
return eq.categoryName === 'Spreader';
|
|
} else if (applicationType === 'liquid') {
|
|
return eq.categoryName === 'Sprayer';
|
|
}
|
|
return true;
|
|
})
|
|
.map(eq => (
|
|
<option key={eq.id} value={eq.id}>
|
|
{eq.customName} - {eq.typeName}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Nozzle Selection - Required for liquid applications */}
|
|
{applicationType === 'liquid' && selectedEquipmentId && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Nozzle Selection *
|
|
<span className="text-xs text-gray-500 ml-1">(Required for spray calculations)</span>
|
|
</label>
|
|
<select
|
|
value={selectedNozzleId}
|
|
onChange={(e) => setSelectedNozzleId(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
required={applicationType === 'liquid'}
|
|
>
|
|
<option value="">Select nozzle</option>
|
|
{nozzles.map(nozzle => (
|
|
<option key={nozzle.id} value={nozzle.id}>
|
|
{nozzle.customName} - {nozzle.manufacturer}
|
|
{nozzle.flowRateGpm && ` (${nozzle.flowRateGpm} GPM)`}
|
|
{nozzle.orificeSize && ` - ${nozzle.orificeSize}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Products */}
|
|
<div>
|
|
<div className="flex justify-between items-center mb-2">
|
|
<label className="block text-sm font-medium text-gray-700">
|
|
Products to Apply
|
|
</label>
|
|
{applicationType === 'liquid' && (
|
|
<button
|
|
type="button"
|
|
onClick={addProduct}
|
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
|
>
|
|
+ Add Product (Tank Mix)
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{selectedProducts.map((product, index) => (
|
|
<div key={index} className="border rounded p-3 mb-3">
|
|
{/* Product Selection */}
|
|
<div className="grid grid-cols-1 gap-3">
|
|
<select
|
|
value={product.uniqueId || ''}
|
|
onChange={(e) => {
|
|
const selectedProduct = products.find(p => (p.uniqueId || p.id) === e.target.value);
|
|
if (selectedProduct) {
|
|
updateProduct(index, 'uniqueId', e.target.value);
|
|
updateProduct(index, 'productId', selectedProduct.isShared ? selectedProduct.id : null);
|
|
updateProduct(index, 'userProductId', !selectedProduct.isShared ? selectedProduct.id : null);
|
|
updateProduct(index, 'isUserProduct', !selectedProduct.isShared);
|
|
// Set product name and brand based on whether it's shared or custom
|
|
const productName = selectedProduct.isShared ?
|
|
selectedProduct.name :
|
|
(selectedProduct.customName || selectedProduct.baseProductName || selectedProduct.name);
|
|
|
|
const productBrand = selectedProduct.isShared ?
|
|
selectedProduct.brand :
|
|
(selectedProduct.customBrand || selectedProduct.brand || 'Unknown Brand');
|
|
|
|
updateProduct(index, 'productName', productName);
|
|
updateProduct(index, 'productBrand', productBrand);
|
|
updateProduct(index, 'productType', selectedProduct.productType);
|
|
|
|
// Pre-populate application rate if available
|
|
console.log('Selected product for rate population:', selectedProduct);
|
|
|
|
let rateSet = false;
|
|
|
|
// For shared products: check rates array
|
|
if (selectedProduct.isShared && selectedProduct.rates && selectedProduct.rates.length > 0) {
|
|
const defaultRate = selectedProduct.rates[0];
|
|
updateProduct(index, 'rateAmount', defaultRate.rateAmount || defaultRate.amount);
|
|
updateProduct(index, 'rateUnit', defaultRate.rateUnit || defaultRate.unit);
|
|
rateSet = true;
|
|
}
|
|
// For custom products: check customRateAmount and customRateUnit
|
|
else if (!selectedProduct.isShared && selectedProduct.customRateAmount) {
|
|
updateProduct(index, 'rateAmount', selectedProduct.customRateAmount);
|
|
updateProduct(index, 'rateUnit', selectedProduct.customRateUnit || (selectedProduct.productType === 'granular' ? 'lb/1000sqft' : 'oz/1000sqft'));
|
|
rateSet = true;
|
|
}
|
|
|
|
// Set default rate unit if no rate was found
|
|
if (!rateSet) {
|
|
if (selectedProduct.productType === 'granular') {
|
|
updateProduct(index, 'rateUnit', 'lb/1000sqft');
|
|
} else if (selectedProduct.productType === 'liquid') {
|
|
updateProduct(index, 'rateUnit', 'oz/1000sqft');
|
|
}
|
|
}
|
|
|
|
// For granular products, check if spreader settings exist
|
|
if (selectedProduct.productType === 'granular' && selectedEquipmentId) {
|
|
checkSpreaderSettings(selectedProduct, selectedEquipmentId);
|
|
}
|
|
}
|
|
}}
|
|
className="border border-gray-300 rounded px-3 py-2"
|
|
required
|
|
>
|
|
<option value="">Select product</option>
|
|
{products
|
|
.filter(prod => !applicationType || prod.productType === applicationType)
|
|
.map(prod => (
|
|
<option key={prod.uniqueId || prod.id} value={prod.uniqueId || prod.id}>
|
|
{/* For shared products: use name and brand */}
|
|
{/* For custom products: use customName or name, and customBrand or brand */}
|
|
{prod.isShared ?
|
|
`${prod.name} - ${prod.brand}` :
|
|
`${prod.customName || prod.baseProductName || prod.name} - ${prod.customBrand || prod.brand || 'Unknown Brand'}`
|
|
}
|
|
{prod.isShared === false && ' (Custom)'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
|
|
{/* Rate Input */}
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Application Rate</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
placeholder="Rate"
|
|
value={product.rateAmount || ''}
|
|
onChange={(e) => updateProduct(index, 'rateAmount', e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
required
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-gray-600 mb-1">Rate Unit</label>
|
|
<select
|
|
value={product.rateUnit || (applicationType === 'granular' ? 'lb/1000sqft' : 'oz/1000sqft')}
|
|
onChange={(e) => updateProduct(index, 'rateUnit', e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
>
|
|
{applicationType === 'granular' ? (
|
|
<>
|
|
<option value="lb/1000sqft">lb/1000sqft</option>
|
|
<option value="oz/1000sqft">oz/1000sqft</option>
|
|
</>
|
|
) : (
|
|
<>
|
|
<option value="oz/1000sqft">oz/1000sqft</option>
|
|
<option value="fl oz/1000sqft">fl oz/1000sqft</option>
|
|
<option value="lb/1000sqft">lb/1000sqft</option>
|
|
</>
|
|
)}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Remove Button */}
|
|
{applicationType === 'liquid' && selectedProducts.length > 1 && (
|
|
<div className="flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => removeProduct(index)}
|
|
className="text-red-600 hover:text-red-800 text-sm"
|
|
>
|
|
Remove Product
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{selectedProducts.length === 0 && (
|
|
<div className="text-center py-4 border-2 border-dashed border-gray-300 rounded">
|
|
<p className="text-gray-500 text-sm">
|
|
{applicationType ? `Select a ${applicationType} product to continue` : 'Select application type first'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Application Calculations */}
|
|
{selectedProducts.length > 0 && selectedAreas.length > 0 && (() => {
|
|
const calculations = calculateApplicationAmounts();
|
|
return calculations ? (
|
|
<div className="border rounded-lg p-4 bg-blue-50">
|
|
<h3 className="font-medium text-blue-900 mb-2">Application Calculations</h3>
|
|
<div className="text-sm text-blue-800">
|
|
<p className="mb-2">Total Area: {calculations.totalArea} sq ft</p>
|
|
{calculations.calculations.map((calc, index) => (
|
|
<div key={index} className="mb-2">
|
|
<p className="font-medium">{calc.productName}:</p>
|
|
<p className="ml-2">Product needed: {calc.totalProductNeeded} {calc.unit}</p>
|
|
{calc.waterNeeded > 0 && <p className="ml-2">Water needed: {calc.waterNeeded} gallons</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
})()}
|
|
|
|
{/* Notes */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Notes (Optional)
|
|
</label>
|
|
<textarea
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
rows={3}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
placeholder="Add any notes or special instructions..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Column - Areas & Map */}
|
|
<div>
|
|
{/* Area Selection with Checkboxes */}
|
|
{selectedPropertyDetails?.sections?.length > 0 && (
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Select Areas to Treat
|
|
</label>
|
|
<div className="max-h-32 overflow-y-auto border rounded p-2 bg-gray-50">
|
|
{selectedPropertyDetails.sections.map(section => (
|
|
<label key={section.id} className="flex items-center py-1 cursor-pointer hover:bg-gray-100 rounded px-2">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedAreas.includes(section.id)}
|
|
onChange={() => handleAreaClick(section)}
|
|
className="mr-2"
|
|
/>
|
|
<span className="text-sm">
|
|
{section.name} ({Math.round(section.area || 0).toLocaleString()} sq ft)
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
|
|
{selectedAreas.length > 0 && (
|
|
<div className="mt-2 p-2 bg-blue-50 rounded">
|
|
<p className="text-sm font-medium text-blue-800">
|
|
Total Selected: {selectedAreas.reduce((total, areaId) => {
|
|
const area = selectedPropertyDetails.sections?.find(s => s.id === areaId);
|
|
return total + (area?.area || 0);
|
|
}, 0).toLocaleString()} sq ft
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Map Display */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
Property Map
|
|
</label>
|
|
<div className="h-64 border rounded-lg overflow-hidden">
|
|
{selectedPropertyDetails ? (
|
|
<PropertyMap
|
|
center={mapCenter}
|
|
zoom={16}
|
|
property={selectedPropertyDetails}
|
|
sections={selectedPropertyDetails.sections || []}
|
|
selectedSections={selectedAreas}
|
|
mode="view"
|
|
editable={false}
|
|
className="h-full w-full"
|
|
/>
|
|
) : (
|
|
<div className="h-full w-full flex items-center justify-center bg-gray-100">
|
|
<p className="text-gray-500">Select a property to view areas</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Form Actions */}
|
|
<div className="flex justify-end gap-3 mt-6 pt-6 border-t">
|
|
<button
|
|
type="button"
|
|
onClick={onClose}
|
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
|
disabled={loading}
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
|
disabled={loading}
|
|
>
|
|
{loading ? 'Saving...' : (editingPlan ? 'Update Plan' : 'Create Plan')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Spreader Settings Form Modal */}
|
|
{showSpreaderSettingsForm && currentProductForSettings && (
|
|
<div className="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center" style={{zIndex: 9999}}>
|
|
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
|
|
<h3 className="text-lg font-semibold mb-4">Add New Setting</h3>
|
|
|
|
{/* Equipment Dropdown */}
|
|
<div className="mb-4">
|
|
<select
|
|
disabled
|
|
value={selectedEquipmentId}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100"
|
|
>
|
|
{equipment
|
|
.filter(eq => eq.id === parseInt(selectedEquipmentId))
|
|
.map(eq => (
|
|
<option key={eq.id} value={eq.id}>
|
|
{eq.customName} ({eq.categoryName?.toLowerCase()})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Setting Input */}
|
|
<div className="mb-4">
|
|
<input
|
|
type="text"
|
|
value={spreaderFormData.setting}
|
|
onChange={(e) => setSpreaderFormData(prev => ({
|
|
...prev,
|
|
setting: e.target.value
|
|
}))}
|
|
className="w-full border border-green-400 rounded px-3 py-2 focus:outline-none focus:border-green-500"
|
|
placeholder="Setting (e.g., #14, 2.5)"
|
|
/>
|
|
</div>
|
|
|
|
{/* Rate Description */}
|
|
<div className="mb-4">
|
|
<input
|
|
type="text"
|
|
value={spreaderFormData.rateDescription}
|
|
onChange={(e) => setSpreaderFormData(prev => ({
|
|
...prev,
|
|
rateDescription: e.target.value
|
|
}))}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
placeholder="Rate description (optional)"
|
|
/>
|
|
</div>
|
|
|
|
{/* Notes */}
|
|
<div className="mb-6">
|
|
<textarea
|
|
rows={3}
|
|
value={spreaderFormData.notes}
|
|
onChange={(e) => setSpreaderFormData(prev => ({
|
|
...prev,
|
|
notes: e.target.value
|
|
}))}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
placeholder="Notes (optional)"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowSpreaderSettingsForm(false);
|
|
setCurrentProductForSettings(null);
|
|
setSpreaderFormData({
|
|
setting: '',
|
|
rateDescription: '',
|
|
notes: ''
|
|
});
|
|
}}
|
|
className="px-4 py-2 text-gray-600 hover:text-gray-800"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={saveSpreaderSettings}
|
|
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
>
|
|
Add Setting
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApplicationPlanModal; |