1098 lines
45 KiB
JavaScript
1098 lines
45 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('');
|
||
// Seed-specific scenario
|
||
const [seedMode, setSeedMode] = useState('overseed');
|
||
const [plannedDate, setPlannedDate] = useState(new Date().toISOString().split('T')[0]);
|
||
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) return;
|
||
// Property and sections
|
||
const propId = editingPlan.property?.id || editingPlan.propertyId;
|
||
if (propId) {
|
||
setSelectedPropertyId(String(propId));
|
||
if (onPropertySelect) onPropertySelect(propId);
|
||
}
|
||
const areaIds = (editingPlan.sections || editingPlan.selectedAreas || []).map(s => (typeof s === 'object' ? s.id : s));
|
||
setSelectedAreas(areaIds);
|
||
|
||
// Equipment/nozzle
|
||
if (editingPlan.equipment?.id || editingPlan.equipmentId) setSelectedEquipmentId(String(editingPlan.equipment?.id || editingPlan.equipmentId));
|
||
if (editingPlan.nozzle?.id || editingPlan.nozzleId) setSelectedNozzleId(String(editingPlan.nozzle?.id || editingPlan.nozzleId));
|
||
|
||
// Date/notes
|
||
setPlannedDate(editingPlan.plannedDate || new Date().toISOString().split('T')[0]);
|
||
setNotes(editingPlan.notes || '');
|
||
|
||
// Determine application type
|
||
const ptypes = (editingPlan.products || []).map(p => (p.productType || '').toLowerCase());
|
||
const derivedType = ptypes.includes('liquid') ? 'liquid' : (ptypes.includes('seed') ? 'seed' : 'granular');
|
||
setApplicationType(derivedType);
|
||
if (derivedType === 'seed') setSeedMode('overseed');
|
||
|
||
// Map products into modal structure
|
||
const mapped = (editingPlan.products || []).map(p => ({
|
||
uniqueId: p.userProductId ? `user_${p.userProductId}` : `shared_${p.productId}`,
|
||
productId: p.productId || null,
|
||
userProductId: p.userProductId || null,
|
||
productName: p.productName,
|
||
productBrand: p.productBrand,
|
||
productType: p.productType,
|
||
rateAmount: p.rateAmount,
|
||
rateUnit: p.rateUnit,
|
||
isUserProduct: !!p.userProductId
|
||
}));
|
||
setSelectedProducts(mapped);
|
||
}, [editingPlan, onPropertySelect]);
|
||
|
||
// 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' || applicationType === 'seed') ? 'lb/1000sqft' : 'oz/1000sqft',
|
||
isUserProduct: false
|
||
}]);
|
||
};
|
||
|
||
// Handle application type change
|
||
const handleApplicationTypeChange = (type) => {
|
||
setApplicationType(type);
|
||
if (type === 'seed') setSeedMode('overseed');
|
||
// Clear products when switching type
|
||
setSelectedProducts([]);
|
||
// Add initial product
|
||
setTimeout(() => {
|
||
setSelectedProducts([{
|
||
uniqueId: '',
|
||
productId: null,
|
||
userProductId: null,
|
||
productName: '',
|
||
productBrand: '',
|
||
productType: type,
|
||
rateAmount: '',
|
||
rateUnit: (type === 'granular' || type === 'seed') ? '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 totalAreaSquareMeters = totalArea * 0.092903; // Convert sq ft to sq meters
|
||
|
||
let waterNeeded = 0;
|
||
|
||
// For liquid applications, calculate water needed once for all products
|
||
if (applicationType === 'liquid' && selectedNozzleId && selectedEquipmentId) {
|
||
const selectedNozzle = nozzles.find(n => n.id === parseInt(selectedNozzleId));
|
||
const selectedEquipment = equipment.find(eq => eq.id === parseInt(selectedEquipmentId));
|
||
|
||
if (selectedNozzle && selectedEquipment && selectedNozzle.flowRateGpm && selectedEquipment.sprayWidthFeet) {
|
||
// Convert units
|
||
const flowRateLPM = selectedNozzle.flowRateGpm * 3.78541; // GPM to L/min
|
||
const widthMeters = selectedEquipment.sprayWidthFeet * 0.3048; // feet to meters
|
||
const speedMPM = 107.29; // Assumed speed in meters/minute (4 km/h)
|
||
|
||
// Carrier application rate: Rate (L/m²) = flow (L/min) / (width (m) × speed (m/min))
|
||
const carrierRate = flowRateLPM / (widthMeters * speedMPM);
|
||
|
||
// Total water needed = carrier rate × total area
|
||
const waterNeededLiters = carrierRate * totalAreaSquareMeters;
|
||
waterNeeded = waterNeededLiters * 0.264172; // Convert liters to gallons
|
||
}
|
||
}
|
||
|
||
const calculations = selectedProducts.map(product => {
|
||
const rateAmount = parseFloat(product.rateAmount) || 0;
|
||
|
||
if (product.productType === 'granular' || product.productType === 'seed') {
|
||
// Granular calculations - total product needed
|
||
const totalProductNeeded = (rateAmount * totalArea) / 1000; // Rate is per 1000 sq ft
|
||
return {
|
||
productName: product.productName,
|
||
totalProductNeeded: totalProductNeeded.toFixed(2),
|
||
unit: product.rateUnit?.replace('/1000sqft', '').replace('/1000 sq ft', '') || 'lbs',
|
||
waterNeeded: 0 // No water for granular
|
||
};
|
||
} else {
|
||
// Liquid calculations - total product needed
|
||
const totalProductNeeded = (rateAmount * totalArea) / 1000; // Rate is per 1000 sq ft
|
||
|
||
return {
|
||
productName: product.productName,
|
||
totalProductNeeded: totalProductNeeded.toFixed(2),
|
||
unit: product.rateUnit?.replace('/1000sqft', '').replace('/1000 sq ft', '') || 'oz',
|
||
waterNeeded: 0 // Water calculated separately for liquid
|
||
};
|
||
}
|
||
});
|
||
|
||
return {
|
||
totalArea: totalArea.toLocaleString(),
|
||
calculations,
|
||
waterNeeded: waterNeeded > 0 ? waterNeeded.toFixed(1) : 0
|
||
};
|
||
};
|
||
|
||
// 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,
|
||
applicationType,
|
||
plannedDate,
|
||
notes: applicationType === 'seed' ? `${notes || ''} [Seeding: ${seedMode.replace('_',' ')}]`.trim() : notes
|
||
};
|
||
|
||
// Add products in the format expected by Applications.js
|
||
if (applicationType === 'liquid') {
|
||
// For liquid: send selectedProducts array with product objects
|
||
planData.selectedProducts = selectedProducts.map(product => ({
|
||
product: products.find(p => (p.uniqueId || p.id) === product.uniqueId),
|
||
rateAmount: product.rateAmount,
|
||
rateUnit: product.rateUnit
|
||
}));
|
||
} else {
|
||
// For granular: send single selectedProduct object
|
||
const firstProduct = selectedProducts[0];
|
||
const productDetails = products.find(p => (p.uniqueId || p.id) === firstProduct.uniqueId);
|
||
|
||
planData.selectedProduct = {
|
||
...productDetails,
|
||
customRateAmount: firstProduct.rateAmount,
|
||
customRateUnit: firstProduct.rateUnit,
|
||
rateAmount: firstProduct.rateAmount,
|
||
rateUnit: firstProduct.rateUnit
|
||
};
|
||
}
|
||
|
||
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="seed"
|
||
checked={applicationType === 'seed'}
|
||
onChange={(e) => handleApplicationTypeChange(e.target.value)}
|
||
className="mr-2"
|
||
/>
|
||
Seed (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' || applicationType === 'seed') {
|
||
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>
|
||
|
||
{/* Seed mode selector */}
|
||
{applicationType === 'seed' && (
|
||
<div className="mb-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Seeding Scenario</label>
|
||
<div className="flex items-center gap-4 text-sm">
|
||
<label className="inline-flex items-center gap-2">
|
||
<input type="radio" name="seed-mode" value="overseed" checked={seedMode==='overseed'} onChange={()=> setSeedMode('overseed')} />
|
||
Overseed
|
||
</label>
|
||
<label className="inline-flex items-center gap-2">
|
||
<input type="radio" name="seed-mode" value="new_seed" checked={seedMode==='new_seed'} onChange={()=> setSeedMode('new_seed')} />
|
||
New lawn
|
||
</label>
|
||
</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) {
|
||
let defaultRate = selectedProduct.rates[0];
|
||
if (applicationType === 'seed') {
|
||
const modeKey = seedMode === 'new_seed' ? 'new' : 'over';
|
||
const match = selectedProduct.rates.find(r => {
|
||
const t = (r.applicationType || '').toLowerCase();
|
||
return t.includes('seed') && t.includes(modeKey);
|
||
});
|
||
if (match) defaultRate = match;
|
||
}
|
||
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' || selectedProduct.productType === 'seed') {
|
||
updateProduct(index, 'rateUnit', 'lb/1000sqft');
|
||
} else if (selectedProduct.productType === 'liquid') {
|
||
updateProduct(index, 'rateUnit', 'oz/1000sqft');
|
||
}
|
||
}
|
||
|
||
// For granular or seed products, check if spreader settings exist
|
||
if ((selectedProduct.productType === 'granular' || selectedProduct.productType === 'seed') && 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' || applicationType === 'seed') ? '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' || applicationType === 'seed') ? (
|
||
<>
|
||
<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>
|
||
</div>
|
||
))}
|
||
{applicationType === 'liquid' && calculations.waterNeeded > 0 && (
|
||
<div className="mt-2 pt-2 border-t border-blue-200">
|
||
<p className="font-medium">Water needed: {calculations.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;
|