Files
turftracker/frontend/src/pages/Applications/Applications.js
Jake Kasper 2dd62fb938 nozzle debug
2025-08-22 16:04:34 -04:00

553 lines
22 KiB
JavaScript

import React, { useState, useEffect } from 'react';
import {
PlusIcon,
MapPinIcon,
BeakerIcon,
WrenchScrewdriverIcon,
CalculatorIcon
} 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);
useEffect(() => {
fetchApplications();
}, []);
const fetchApplications = async () => {
try {
setLoading(true);
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');
} finally {
setLoading(false);
}
};
if (loading) {
return (
<div className="p-6">
<div className="flex justify-center items-center h-64">
<LoadingSpinner size="lg" />
</div>
</div>
);
}
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Applications</h1>
<p className="text-gray-600">Plan, track, and log your lawn applications</p>
</div>
<button
onClick={() => setShowPlanForm(true)}
className="btn-primary flex items-center gap-2"
>
<PlusIcon className="h-5 w-5" />
Plan Application
</button>
</div>
{/* Applications List */}
{applications.length === 0 ? (
<div className="card text-center py-12">
<CalculatorIcon className="h-16 w-16 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Applications Yet</h3>
<p className="text-gray-600 mb-6">
Start by planning your first lawn application
</p>
<button
onClick={() => setShowPlanForm(true)}
className="btn-primary"
>
Plan Your First Application
</button>
</div>
) : (
<div className="space-y-4">
{applications.map((application) => (
<div key={application.id} className="card">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="font-semibold text-gray-900">
{application.propertyName} - {application.sectionName}
</h3>
<span className={`px-2 py-1 text-xs font-medium rounded-full ${
application.status === 'planned' ? 'bg-blue-100 text-blue-800' :
application.status === 'completed' ? 'bg-green-100 text-green-800' :
application.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
'bg-gray-100 text-gray-800'
}`}>
{application.status}
</span>
</div>
<p className="text-sm text-gray-600 mb-1">
<MapPinIcon className="h-4 w-4 inline mr-1" />
{application.propertyAddress}
</p>
<p className="text-sm text-gray-600 mb-1">
Area: {Math.round(application.sectionArea).toLocaleString()} sq ft
</p>
<p className="text-sm text-gray-600 mb-1">
Equipment: {application.equipmentName}
</p>
<p className="text-sm text-gray-600">
Products: {application.productCount}
</p>
{application.notes && (
<p className="text-sm text-gray-500 mt-2 italic">
"{application.notes}"
</p>
)}
</div>
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{application.plannedDate ? new Date(application.plannedDate).toLocaleDateString() : 'No date set'}
</p>
<p className="text-xs text-gray-500">
Created {new Date(application.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</div>
))}
</div>
)}
{/* Plan Application Modal */}
{showPlanForm && (
<ApplicationPlanModal
onClose={() => setShowPlanForm(false)}
onSubmit={async (planData) => {
try {
// Create a plan for each selected area
const planPromises = planData.selectedAreas.map(async (areaId) => {
const planPayload = {
lawnSectionId: parseInt(areaId),
equipmentId: parseInt(planData.equipmentId),
plannedDate: new Date().toISOString().split('T')[0], // Default to today
notes: planData.notes || '', // Ensure notes is never null/undefined
products: [{
...(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'
}]
};
return applicationsAPI.createPlan(planPayload);
});
await Promise.all(planPromises);
toast.success(`Created ${planData.selectedAreas.length} application plan(s) successfully`);
setShowPlanForm(false);
fetchApplications();
} catch (error) {
console.error('Failed to create application plans:', error);
toast.error('Failed to create application plans');
}
}}
/>
)}
</div>
);
};
// Application Planning Modal Component
const ApplicationPlanModal = ({ onClose, onSubmit }) => {
const [properties, setProperties] = useState([]);
const [products, setProducts] = useState([]);
const [equipment, setEquipment] = useState([]);
const [nozzles, setNozzles] = useState([]);
const [selectedPropertyDetails, setSelectedPropertyDetails] = useState(null);
const [loading, setLoading] = useState(true);
const [loadingProperty, setLoadingProperty] = useState(false);
const [planData, setPlanData] = useState({
propertyId: '',
selectedAreas: [],
productId: '',
selectedProduct: null,
applicationType: '', // 'liquid' or 'granular'
equipmentId: '',
nozzleId: '',
notes: ''
});
useEffect(() => {
fetchPlanningData();
}, []);
const fetchPlanningData = async () => {
try {
setLoading(true);
const [propertiesResponse, productsResponse, equipmentResponse, nozzlesResponse] = await Promise.all([
propertiesAPI.getAll(),
productsAPI.getAll(),
equipmentAPI.getAll(),
nozzlesAPI.getAll()
]);
console.log('Properties response:', propertiesResponse.data);
console.log('Products response:', productsResponse.data);
console.log('Equipment response:', equipmentResponse.data);
console.log('Nozzles response:', nozzlesResponse.data);
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) => {
try {
setLoadingProperty(true);
const response = await propertiesAPI.getById(parseInt(propertyId));
console.log('Property details response:', response.data);
const property = response.data.data.property;
console.log('Property sections with polygon data:', property.sections?.map(s => ({
id: s.id,
name: s.name,
hasPolygonData: !!s.polygonData,
polygonDataType: typeof s.polygonData,
coordinates: s.polygonData?.coordinates?.[0]?.length || 0
})));
setSelectedPropertyDetails(property);
} catch (error) {
console.error('Failed to fetch property details:', error);
toast.error('Failed to load property details');
setSelectedPropertyDetails(null);
} finally {
setLoadingProperty(false);
}
};
const handlePropertyChange = (propertyId) => {
setPlanData({ ...planData, propertyId, selectedAreas: [] });
setSelectedPropertyDetails(null);
if (propertyId) {
fetchPropertyDetails(propertyId);
}
};
// 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.productId) {
toast.error('Please select a product');
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 (
<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-4xl max-h-[90vh] overflow-y-auto">
<h3 className="text-lg font-semibold mb-4">Plan Application</h3>
{loading ? (
<LoadingSpinner />
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Property Selection */}
<div>
<label className="label flex items-center gap-2">
<MapPinIcon className="h-5 w-5" />
Property *
</label>
<select
className="input"
value={planData.propertyId}
onChange={(e) => handlePropertyChange(e.target.value)}
required
>
<option value="">Select a property...</option>
{properties.map((property) => (
<option key={property.id} value={property.id}>
{property.name} - {property.address}
</option>
))}
</select>
</div>
{/* Area Selection with Map */}
{loadingProperty && (
<div className="flex items-center gap-2 text-gray-600">
<LoadingSpinner size="sm" />
<span>Loading property details...</span>
</div>
)}
{selectedPropertyDetails && (
<div>
<label className="label">Application Areas *</label>
{/* Property Map */}
{selectedPropertyDetails.latitude && selectedPropertyDetails.longitude && (
<div className="mb-4">
<div className="relative">
<PropertyMap
center={[selectedPropertyDetails.latitude, selectedPropertyDetails.longitude]}
zoom={18}
property={selectedPropertyDetails}
sections={selectedPropertyDetails.sections || []}
editable={false}
className="h-64 w-full"
selectedSections={planData.selectedAreas}
onSectionClick={(section) => handleAreaToggle(section.id)}
/>
<div className="absolute top-2 left-2 bg-white rounded-lg shadow-lg p-2 text-xs">
<p className="font-medium text-gray-700">Click sections to select</p>
{planData.selectedAreas.length > 0 && (
<p className="text-blue-600">{planData.selectedAreas.length} selected</p>
)}
</div>
</div>
</div>
)}
{selectedPropertyDetails.sections && selectedPropertyDetails.sections.length > 0 ? (
<div>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{selectedPropertyDetails.sections.map((section) => (
<label key={section.id} className="flex items-center">
<input
type="checkbox"
checked={planData.selectedAreas.includes(section.id)}
onChange={() => handleAreaToggle(section.id)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="ml-2 text-sm">
{section.name} ({section.area ? `${Math.round(section.area)} sq ft` : 'No size'})
</span>
</label>
))}
</div>
{planData.selectedAreas.length > 0 && (
<p className="text-sm text-gray-600 mt-2">
Total area: {selectedPropertyDetails.sections
.filter(s => planData.selectedAreas.includes(s.id))
.reduce((total, s) => total + (s.area || 0), 0).toFixed(0)} sq ft
</p>
)}
</div>
) : (
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-yellow-800 text-sm">
This property has no lawn sections defined. Please add lawn sections to the property first.
</p>
</div>
)}
</div>
)}
{/* Product Selection */}
<div>
<label className="label flex items-center gap-2">
<BeakerIcon className="h-5 w-5" />
Product *
</label>
<select
className="input"
value={planData.productId}
onChange={(e) => {
const selectedProduct = products.find(p => p.uniqueId === e.target.value);
console.log('Selected product:', selectedProduct);
// Determine application type from product type
let applicationType = '';
if (selectedProduct) {
const productType = selectedProduct.productType || selectedProduct.customProductType;
// Map product types to application types
if (productType && (productType.toLowerCase().includes('liquid') || productType.toLowerCase().includes('concentrate'))) {
applicationType = 'liquid';
} else if (productType && (productType.toLowerCase().includes('granular') || productType.toLowerCase().includes('granule'))) {
applicationType = 'granular';
}
}
setPlanData({
...planData,
productId: e.target.value,
selectedProduct: selectedProduct,
applicationType: applicationType
});
}}
required
>
<option value="">Select a product...</option>
{products.map((product) => {
const displayName = product.customName || product.name;
const productType = product.productType || product.customProductType;
const brand = product.brand || product.customBrand;
const rateInfo = product.customRateAmount && product.customRateUnit
? ` (${product.customRateAmount} ${product.customRateUnit})`
: '';
return (
<option key={product.uniqueId} value={product.uniqueId}>
{displayName}{brand ? ` - ${brand}` : ''}{productType ? ` (${productType})` : ''}{rateInfo}
</option>
);
})}
</select>
</div>
{/* Equipment Selection */}
{planData.applicationType && (
<div>
<label className="label flex items-center gap-2">
<WrenchScrewdriverIcon className="h-5 w-5" />
Equipment * ({planData.applicationType})
</label>
<select
className="input"
value={planData.equipmentId}
onChange={(e) => setPlanData({ ...planData, equipmentId: e.target.value })}
required
>
<option value="">Select equipment...</option>
{availableEquipment.map((eq) => (
<option key={eq.id} value={eq.id}>
{eq.customName || eq.typeName}
{eq.manufacturer && ` - ${eq.manufacturer}`}
{eq.tankSizeGallons && ` (${eq.tankSizeGallons} gal)`}
{eq.capacityLbs && ` (${eq.capacityLbs} lbs)`}
</option>
))}
</select>
{availableEquipment.length === 0 && (
<p className="text-sm text-orange-600 mt-1">
No {planData.applicationType === 'liquid' ? 'sprayers' : 'spreaders'} found.
Please add equipment first.
</p>
)}
</div>
)}
{/* Nozzle Selection for Liquid Applications */}
{planData.applicationType === 'liquid' && (
<div>
<label className="label flex items-center gap-2">
<BeakerIcon className="h-5 w-5" />
Nozzle Selection
</label>
<select
className="input"
value={planData.nozzleId}
onChange={(e) => setPlanData({ ...planData, nozzleId: e.target.value })}
>
<option value="">Select nozzle (optional)...</option>
{nozzles.map((nozzle) => (
<option key={nozzle.id} value={nozzle.id}>
{nozzle.customName || nozzle.typeName}
{nozzle.orificeSize && ` - ${nozzle.orificeSize}`}
{nozzle.flowRateGpm && ` (${nozzle.flowRateGpm} GPM)`}
{nozzle.sprayAngle && ` ${nozzle.sprayAngle}°`}
</option>
))}
</select>
{nozzles.length === 0 && (
<p className="text-sm text-orange-600 mt-1">
No nozzles found. Go to Equipment Add Equipment Select "Nozzle" category to add nozzles first.
</p>
)}
</div>
)}
{/* Notes */}
<div>
<label className="label">Notes</label>
<textarea
className="input"
rows="3"
value={planData.notes}
onChange={(e) => setPlanData({ ...planData, notes: e.target.value })}
placeholder="Application notes, weather conditions, etc."
/>
</div>
<div className="flex gap-3 pt-4">
<button type="submit" className="btn-primary flex-1">
Create Application Plan
</button>
<button
type="button"
onClick={onClose}
className="btn-secondary flex-1"
>
Cancel
</button>
</div>
</form>
)}
</div>
</div>
);
};
export default Applications;