560 lines
21 KiB
JavaScript
560 lines
21 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);
|
|
|
|
// 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 - selectedPropertyDetails:', selectedPropertyDetails);
|
|
console.log('ApplicationPlanModal - mapCenter:', mapCenter);
|
|
console.log('ApplicationPlanModal - sections:', selectedPropertyDetails?.sections);
|
|
}, [selectedPropertyDetails, mapCenter]);
|
|
|
|
// 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
|
|
));
|
|
};
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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>
|
|
|
|
{/* Equipment Selection */}
|
|
<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.map(eq => (
|
|
<option key={eq.id} value={eq.id}>
|
|
{eq.name || 'Unnamed Equipment'} {eq.type ? `(${eq.type})` : ''}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Nozzle Selection (for sprayers) */}
|
|
{selectedEquipmentId && equipment.find(eq => eq.id == selectedEquipmentId)?.type === 'sprayer' && (
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
Nozzle (Optional)
|
|
</label>
|
|
<select
|
|
value={selectedNozzleId}
|
|
onChange={(e) => setSelectedNozzleId(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
>
|
|
<option value="">No specific nozzle</option>
|
|
{nozzles.map(nozzle => (
|
|
<option key={nozzle.id} value={nozzle.id}>
|
|
{nozzle.name} - {nozzle.flow_rate} GPM
|
|
</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 */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
<BeakerIcon className="h-4 w-4 inline mr-1" />
|
|
Application Type
|
|
</label>
|
|
<div className="flex gap-4 mb-3">
|
|
<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>
|
|
|
|
{/* 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 === 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);
|
|
updateProduct(index, 'productName', selectedProduct.name);
|
|
updateProduct(index, 'productBrand', selectedProduct.brand);
|
|
updateProduct(index, 'productType', selectedProduct.productType);
|
|
}
|
|
}}
|
|
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} value={prod.uniqueId}>
|
|
{prod.name} {prod.brand && `(${prod.brand})`} {prod.isShared ? '' : '(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>
|
|
|
|
{/* 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>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ApplicationPlanModal; |