953 lines
40 KiB
JavaScript
953 lines
40 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
PlusIcon,
|
|
MapPinIcon,
|
|
BeakerIcon,
|
|
WrenchScrewdriverIcon,
|
|
CalculatorIcon,
|
|
PencilIcon,
|
|
TrashIcon,
|
|
PlayIcon,
|
|
EyeIcon,
|
|
ArchiveBoxIcon,
|
|
FunnelIcon,
|
|
XMarkIcon
|
|
} 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 ApplicationExecutionModal from '../../components/Applications/ApplicationExecutionModal';
|
|
import ApplicationPlanModal from '../../components/Applications/ApplicationPlanModal';
|
|
import ApplicationViewModal from '../../components/Applications/ApplicationViewModal';
|
|
import toast from 'react-hot-toast';
|
|
|
|
const Applications = () => {
|
|
const [showPlanForm, setShowPlanForm] = useState(false);
|
|
const [applications, setApplications] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [properties, setProperties] = useState([]);
|
|
const [products, setProducts] = useState([]);
|
|
const [equipment, setEquipment] = useState([]);
|
|
const [nozzles, setNozzles] = useState([]);
|
|
const [selectedPropertyDetails, setSelectedPropertyDetails] = useState(null);
|
|
const [editingPlan, setEditingPlan] = useState(null);
|
|
const [propertyCache, setPropertyCache] = useState({});
|
|
const [spreaderRecommendation, setSpreaderRecommendation] = useState(null);
|
|
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
|
|
const [executingApplication, setExecutingApplication] = useState(null);
|
|
const [showExecutionModal, setShowExecutionModal] = useState(false);
|
|
const [showViewModal, setShowViewModal] = useState(false);
|
|
const [viewingApplication, setViewingApplication] = useState(null);
|
|
|
|
// Filtering and sorting state
|
|
const [showFilters, setShowFilters] = useState(false);
|
|
const [filters, setFilters] = useState({
|
|
status: 'all',
|
|
dateRange: 'all',
|
|
product: 'all',
|
|
minArea: '',
|
|
maxArea: '',
|
|
property: 'all'
|
|
});
|
|
const [sortBy, setSortBy] = useState('date');
|
|
const [sortOrder, setSortOrder] = useState('desc');
|
|
|
|
useEffect(() => {
|
|
fetchApplications();
|
|
fetchPlanningData();
|
|
}, []);
|
|
|
|
const fetchApplications = async () => {
|
|
try {
|
|
const response = await applicationsAPI.getPlans();
|
|
setApplications(response.data.data.plans || []);
|
|
} catch (error) {
|
|
console.error('Failed to fetch applications:', error);
|
|
toast.error('Failed to load applications');
|
|
}
|
|
};
|
|
|
|
const fetchPlanningData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [propertiesResponse, productsResponse, equipmentResponse, nozzlesResponse] = await Promise.all([
|
|
propertiesAPI.getAll(),
|
|
productsAPI.getAll(),
|
|
equipmentAPI.getAll(),
|
|
nozzlesAPI.getAll()
|
|
]);
|
|
|
|
setProperties(propertiesResponse.data.data.properties || []);
|
|
// Combine shared and user products with unique IDs
|
|
const sharedProducts = (productsResponse.data.data.sharedProducts || []).map(product => ({
|
|
...product,
|
|
uniqueId: `shared_${product.id}`,
|
|
isShared: true
|
|
}));
|
|
|
|
const userProducts = (productsResponse.data.data.userProducts || []).map(product => ({
|
|
...product,
|
|
uniqueId: `user_${product.id}`,
|
|
isShared: false
|
|
}));
|
|
|
|
const allProducts = [...sharedProducts, ...userProducts];
|
|
setProducts(allProducts);
|
|
setEquipment(equipmentResponse.data.data.equipment || []);
|
|
setNozzles(nozzlesResponse.data.data?.nozzles || nozzlesResponse.data || []);
|
|
} catch (error) {
|
|
console.error('Failed to fetch planning data:', error);
|
|
toast.error('Failed to load planning data');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchPropertyDetails = async (propertyId) => {
|
|
// Validate propertyId
|
|
if (!propertyId || isNaN(parseInt(propertyId))) {
|
|
console.error('Invalid property ID:', propertyId);
|
|
toast.error('Invalid property ID');
|
|
setSelectedPropertyDetails(null);
|
|
return null;
|
|
}
|
|
|
|
// Check cache first
|
|
if (propertyCache[propertyId]) {
|
|
setSelectedPropertyDetails(propertyCache[propertyId]);
|
|
return propertyCache[propertyId];
|
|
}
|
|
|
|
try {
|
|
const response = await propertiesAPI.getById(parseInt(propertyId));
|
|
const property = response.data.data.property;
|
|
|
|
// Cache the result
|
|
setPropertyCache(prev => ({ ...prev, [propertyId]: property }));
|
|
setSelectedPropertyDetails(property);
|
|
return property;
|
|
} catch (error) {
|
|
console.error('Failed to fetch property details:', error);
|
|
toast.error('Failed to load property details');
|
|
setSelectedPropertyDetails(null);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const handleDeletePlan = async (planId, planName) => {
|
|
if (window.confirm(`Are you sure you want to delete the plan for "${planName}"?`)) {
|
|
try {
|
|
await applicationsAPI.deletePlan(planId);
|
|
toast.success('Application plan deleted successfully');
|
|
fetchApplications(); // Refresh the list
|
|
} catch (error) {
|
|
console.error('Failed to delete plan:', error);
|
|
toast.error('Failed to delete application plan');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleEditPlan = async (planId) => {
|
|
try {
|
|
// Fetch the full plan details
|
|
const response = await applicationsAPI.getPlan(planId);
|
|
const plan = response.data.data.plan;
|
|
|
|
// Set up the editing plan data
|
|
setEditingPlan(plan);
|
|
setShowPlanForm(true);
|
|
} catch (error) {
|
|
console.error('Failed to fetch plan details:', error);
|
|
toast.error('Failed to load plan details');
|
|
}
|
|
};
|
|
|
|
const handleExecuteApplication = async (application) => {
|
|
try {
|
|
// Set the executing application and show the modal
|
|
setExecutingApplication(application);
|
|
|
|
// Get the property ID from the application
|
|
const propertyId = application.property?.id || application.section?.propertyId;
|
|
|
|
// Try to fetch property details if we have a valid property ID
|
|
if (propertyId && (!selectedPropertyDetails || selectedPropertyDetails.id !== propertyId)) {
|
|
await fetchPropertyDetails(propertyId);
|
|
} else if (!propertyId) {
|
|
console.warn('No property ID found for application:', application);
|
|
// Clear any existing property details since this application doesn't have property info
|
|
setSelectedPropertyDetails(null);
|
|
}
|
|
|
|
setShowExecutionModal(true);
|
|
} catch (error) {
|
|
console.error('Failed to start application execution:', error);
|
|
toast.error('Failed to start application execution');
|
|
}
|
|
};
|
|
|
|
const handleViewApplication = async (application) => {
|
|
try {
|
|
// Set the viewing application and show the modal
|
|
setViewingApplication(application);
|
|
|
|
// Get the property ID from the application
|
|
const propertyId = application.property?.id || application.section?.propertyId;
|
|
|
|
// Try to fetch property details if we have a valid property ID
|
|
if (propertyId && (!selectedPropertyDetails || selectedPropertyDetails.id !== propertyId)) {
|
|
await fetchPropertyDetails(propertyId);
|
|
} else if (!propertyId) {
|
|
console.warn('No property ID found for application:', application);
|
|
// Clear any existing property details since this application doesn't have property info
|
|
setSelectedPropertyDetails(null);
|
|
}
|
|
|
|
setShowViewModal(true);
|
|
} catch (error) {
|
|
console.error('Failed to load application details:', error);
|
|
toast.error('Failed to load application details');
|
|
}
|
|
};
|
|
|
|
const handleArchiveApplication = async (applicationId) => {
|
|
if (!window.confirm('Are you sure you want to archive this application? It will be moved to the archive and hidden from the main list.')) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await applicationsAPI.updatePlanStatus(applicationId, 'archived');
|
|
toast.success('Application archived successfully');
|
|
fetchApplications(); // Refresh the list
|
|
} catch (error) {
|
|
console.error('Failed to archive application:', error);
|
|
toast.error('Failed to archive application');
|
|
}
|
|
};
|
|
|
|
// Filter and sort applications
|
|
const filteredAndSortedApplications = React.useMemo(() => {
|
|
let filtered = applications.filter(app => {
|
|
// Hide archived applications unless specifically filtering for them
|
|
if (app.status === 'archived' && filters.status !== 'archived') {
|
|
return false;
|
|
}
|
|
|
|
// Status filter
|
|
if (filters.status !== 'all' && app.status !== filters.status) {
|
|
return false;
|
|
}
|
|
|
|
// Date range filter
|
|
if (filters.dateRange !== 'all') {
|
|
const appDate = new Date(app.plannedDate);
|
|
const now = new Date();
|
|
switch (filters.dateRange) {
|
|
case 'today':
|
|
if (appDate.toDateString() !== now.toDateString()) return false;
|
|
break;
|
|
case 'week':
|
|
const weekAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
|
if (appDate < weekAgo) return false;
|
|
break;
|
|
case 'month':
|
|
const monthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
|
if (appDate < monthAgo) return false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Area filter
|
|
const area = app.totalSectionArea || app.sectionArea || 0;
|
|
if (filters.minArea && area < parseInt(filters.minArea)) {
|
|
return false;
|
|
}
|
|
if (filters.maxArea && area > parseInt(filters.maxArea)) {
|
|
return false;
|
|
}
|
|
|
|
// Property filter
|
|
if (filters.property !== 'all' && app.propertyName !== filters.property) {
|
|
return false;
|
|
}
|
|
|
|
// Product filter
|
|
if (filters.product !== 'all') {
|
|
const hasProduct = app.productDetails?.some(p => p.name.toLowerCase().includes(filters.product.toLowerCase())) ||
|
|
app.products?.some(p => p.productName?.toLowerCase().includes(filters.product.toLowerCase()));
|
|
if (!hasProduct) return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
|
|
// Sort
|
|
return filtered.sort((a, b) => {
|
|
let aVal, bVal;
|
|
switch (sortBy) {
|
|
case 'date':
|
|
aVal = new Date(a.plannedDate);
|
|
bVal = new Date(b.plannedDate);
|
|
break;
|
|
case 'area':
|
|
aVal = a.totalSectionArea || a.sectionArea || 0;
|
|
bVal = b.totalSectionArea || b.sectionArea || 0;
|
|
break;
|
|
case 'property':
|
|
aVal = a.propertyName || '';
|
|
bVal = b.propertyName || '';
|
|
break;
|
|
case 'status':
|
|
aVal = a.status || '';
|
|
bVal = b.status || '';
|
|
break;
|
|
default:
|
|
return 0;
|
|
}
|
|
|
|
if (sortOrder === 'desc') {
|
|
return bVal > aVal ? 1 : bVal < aVal ? -1 : 0;
|
|
} else {
|
|
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
|
|
}
|
|
});
|
|
}, [applications, filters, sortBy, sortOrder]);
|
|
|
|
// Get unique values for filter dropdowns
|
|
const uniqueProperties = React.useMemo(() => {
|
|
const props = [...new Set(applications.map(app => app.propertyName).filter(Boolean))];
|
|
return props.sort();
|
|
}, [applications]);
|
|
|
|
const uniqueProducts = React.useMemo(() => {
|
|
const products = new Set();
|
|
applications.forEach(app => {
|
|
app.productDetails?.forEach(p => products.add(p.name));
|
|
app.products?.forEach(p => p.productName && products.add(p.productName));
|
|
});
|
|
return [...products].sort();
|
|
}, [applications]);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="p-6">
|
|
<div className="flex justify-center items-center h-64">
|
|
<LoadingSpinner size="lg" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Load spreader recommendations when granular product and spreader are selected
|
|
const loadSpreaderRecommendation = async (product, equipmentId, selectedAreas) => {
|
|
if (!product || !equipmentId || !selectedAreas.length || product.productType !== 'granular') {
|
|
setSpreaderRecommendation(null);
|
|
return;
|
|
}
|
|
|
|
setLoadingRecommendation(true);
|
|
try {
|
|
// Find the selected equipment details
|
|
const selectedEquipment = equipment.find(eq => eq.id === parseInt(equipmentId));
|
|
if (!selectedEquipment) {
|
|
setSpreaderRecommendation(null);
|
|
return;
|
|
}
|
|
|
|
// Load spreader settings for this product
|
|
const productApiId = product.isShared ? product.id : product.id; // Use the actual product ID
|
|
const endpoint = product.isShared
|
|
? `/api/product-spreader-settings/product/${productApiId}`
|
|
: `/api/product-spreader-settings/user-product/${productApiId}`;
|
|
|
|
const response = await fetch(endpoint, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
setSpreaderRecommendation(null);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
const settings = data.data?.settings || [];
|
|
|
|
// Find a matching setting for this equipment
|
|
let matchingSetting = null;
|
|
|
|
// First try to find exact equipment match
|
|
matchingSetting = settings.find(setting =>
|
|
setting.equipmentId === selectedEquipment.id
|
|
);
|
|
|
|
// If no exact match, try to find by equipment brand/manufacturer
|
|
if (!matchingSetting && selectedEquipment.manufacturer) {
|
|
matchingSetting = settings.find(setting =>
|
|
setting.spreaderBrand &&
|
|
setting.spreaderBrand.toLowerCase().includes(selectedEquipment.manufacturer.toLowerCase())
|
|
);
|
|
}
|
|
|
|
// If still no match, use any available setting as fallback
|
|
if (!matchingSetting && settings.length > 0) {
|
|
matchingSetting = settings[0];
|
|
}
|
|
|
|
if (matchingSetting) {
|
|
// Calculate total area and product amount needed
|
|
const totalArea = selectedAreas.reduce((sum, areaId) => {
|
|
const area = selectedPropertyDetails?.sections?.find(s => s.id === areaId);
|
|
return sum + (area?.area || 0);
|
|
}, 0);
|
|
|
|
// Calculate product amount based on rate
|
|
const rateAmount = product.customRateAmount || product.rateAmount || 1;
|
|
const rateUnit = product.customRateUnit || product.rateUnit || 'lbs/1000 sq ft';
|
|
|
|
let productAmountLbs = 0;
|
|
if (rateUnit.includes('1000')) {
|
|
// Rate per 1000 sq ft
|
|
productAmountLbs = (rateAmount * totalArea) / 1000;
|
|
} else if (rateUnit.includes('acre')) {
|
|
// Rate per acre (43,560 sq ft)
|
|
productAmountLbs = (rateAmount * totalArea) / 43560;
|
|
} else {
|
|
// Assume rate per sq ft
|
|
productAmountLbs = rateAmount * totalArea;
|
|
}
|
|
|
|
setSpreaderRecommendation({
|
|
setting: matchingSetting,
|
|
equipment: selectedEquipment,
|
|
totalArea,
|
|
productAmountLbs: Math.round(productAmountLbs * 100) / 100, // Round to 2 decimal places
|
|
isExactMatch: settings.some(s => s.equipmentId === selectedEquipment.id)
|
|
});
|
|
} else {
|
|
setSpreaderRecommendation(null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load spreader recommendation:', error);
|
|
setSpreaderRecommendation(null);
|
|
} finally {
|
|
setLoadingRecommendation(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<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={() => {
|
|
setSelectedPropertyDetails(null); // Clear previous property data
|
|
setShowPlanForm(true);
|
|
}}
|
|
className="btn-primary flex items-center gap-2"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Plan Application
|
|
</button>
|
|
</div>
|
|
|
|
{/* Filter Controls */}
|
|
{applications.length > 0 && (
|
|
<div className="mb-6">
|
|
<div className="bg-white p-4 rounded-lg shadow">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<button
|
|
onClick={() => setShowFilters(!showFilters)}
|
|
className="flex items-center gap-2 text-gray-700 hover:text-gray-900"
|
|
>
|
|
<FunnelIcon className="h-5 w-5" />
|
|
Filters & Sort
|
|
{showFilters && <span className="text-sm text-gray-500">(hide)</span>}
|
|
</button>
|
|
<div className="text-sm text-gray-600">
|
|
{filteredAndSortedApplications.length} of {applications.length} applications
|
|
</div>
|
|
</div>
|
|
|
|
{showFilters && (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
{/* Status Filter */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
|
<select
|
|
value={filters.status}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, status: e.target.value }))}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="all">All Status</option>
|
|
<option value="planned">Planned</option>
|
|
<option value="in_progress">In Progress</option>
|
|
<option value="completed">Completed</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Date Range Filter */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Date Range</label>
|
|
<select
|
|
value={filters.dateRange}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, dateRange: e.target.value }))}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="all">All Dates</option>
|
|
<option value="today">Today</option>
|
|
<option value="week">Last Week</option>
|
|
<option value="month">Last Month</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Property Filter */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Property</label>
|
|
<select
|
|
value={filters.property}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, property: e.target.value }))}
|
|
className="w-full border border-gray-300 rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="all">All Properties</option>
|
|
{uniqueProperties.map(property => (
|
|
<option key={property} value={property}>{property}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Sort By */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Sort By</label>
|
|
<div className="flex gap-2">
|
|
<select
|
|
value={sortBy}
|
|
onChange={(e) => setSortBy(e.target.value)}
|
|
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm"
|
|
>
|
|
<option value="date">Date</option>
|
|
<option value="property">Property</option>
|
|
<option value="area">Area Size</option>
|
|
<option value="status">Status</option>
|
|
</select>
|
|
<button
|
|
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
className="px-3 py-2 border border-gray-300 rounded text-sm hover:bg-gray-50"
|
|
>
|
|
{sortOrder === 'desc' ? '↓' : '↑'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Area Range Filter */}
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Area Range (sq ft)</label>
|
|
<div className="flex gap-2 items-center">
|
|
<input
|
|
type="number"
|
|
value={filters.minArea}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, minArea: e.target.value }))}
|
|
placeholder="Min"
|
|
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm"
|
|
/>
|
|
<span className="text-gray-500">to</span>
|
|
<input
|
|
type="number"
|
|
value={filters.maxArea}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, maxArea: e.target.value }))}
|
|
placeholder="Max"
|
|
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Product Filter */}
|
|
<div className="md:col-span-2">
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Product Contains</label>
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="text"
|
|
value={filters.product}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, product: e.target.value }))}
|
|
placeholder="Search products..."
|
|
className="flex-1 border border-gray-300 rounded px-3 py-2 text-sm"
|
|
/>
|
|
{filters.product !== 'all' && filters.product !== '' && (
|
|
<button
|
|
onClick={() => setFilters(prev => ({ ...prev, product: 'all' }))}
|
|
className="px-3 py-2 text-gray-400 hover:text-gray-600"
|
|
>
|
|
<XMarkIcon className="h-4 w-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Clear Filters Button */}
|
|
<div className="md:col-span-4 pt-2">
|
|
<button
|
|
onClick={() => {
|
|
setFilters({
|
|
status: 'all',
|
|
dateRange: 'all',
|
|
product: 'all',
|
|
minArea: '',
|
|
maxArea: '',
|
|
property: 'all'
|
|
});
|
|
setSortBy('date');
|
|
setSortOrder('desc');
|
|
}}
|
|
className="text-sm text-blue-600 hover:text-blue-800"
|
|
>
|
|
Clear all filters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</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={() => {
|
|
setSelectedPropertyDetails(null); // Clear previous property data
|
|
setShowPlanForm(true);
|
|
}}
|
|
className="btn-primary"
|
|
>
|
|
Plan Your First Application
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{filteredAndSortedApplications.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">
|
|
Areas: {application.sectionNames} ({Math.round(application.totalSectionArea || application.sectionArea || 0).toLocaleString()} sq ft)
|
|
</p>
|
|
<p className="text-sm text-gray-600 mb-1">
|
|
Equipment: {application.equipmentName}
|
|
</p>
|
|
<p className="text-sm text-gray-600 mb-1">
|
|
Products: {application.productCount}
|
|
</p>
|
|
{/* Display calculated amounts */}
|
|
{(application.totalProductAmount > 0 || (application.productDetails && application.productDetails.length > 0)) && (
|
|
<div className="text-sm text-green-600 mt-2 space-y-1">
|
|
<p className="font-medium">Calculated Requirements:</p>
|
|
|
|
{/* Show individual products for liquid tank mix */}
|
|
{application.productDetails && application.productDetails.length > 1 ? (
|
|
<>
|
|
{application.productDetails.map((product, index) => (
|
|
<p key={index}>
|
|
• {product.name}{product.brand ? ` (${product.brand})` : ''}: {product.calculatedAmount.toFixed(2)} oz
|
|
</p>
|
|
))}
|
|
{application.totalWaterAmount > 0 && (
|
|
<p>• Water: {application.totalWaterAmount.toFixed(2)} gallons</p>
|
|
)}
|
|
{application.avgSpeedMph > 0 && (
|
|
<p>• Target Speed: {application.avgSpeedMph.toFixed(1)} mph</p>
|
|
)}
|
|
{application.spreaderSetting && (
|
|
<p>• Spreader Setting: {application.spreaderSetting}</p>
|
|
)}
|
|
</>
|
|
) : (
|
|
<>
|
|
{/* Show single product with name */}
|
|
{application.productDetails && application.productDetails.length === 1 ? (
|
|
<p>• {application.productDetails[0].name}{application.productDetails[0].brand ? ` (${application.productDetails[0].brand})` : ''}: {application.productDetails[0].calculatedAmount.toFixed(2)} {application.totalWaterAmount > 0 ? 'oz' : 'lbs'}</p>
|
|
) : (
|
|
<p>• Product: {application.totalProductAmount.toFixed(2)} {application.totalWaterAmount > 0 ? 'oz' : 'lbs'}</p>
|
|
)}
|
|
{application.totalWaterAmount > 0 && (
|
|
<p>• Water: {application.totalWaterAmount.toFixed(2)} gallons</p>
|
|
)}
|
|
{application.avgSpeedMph > 0 && (
|
|
<p>• Target Speed: {application.avgSpeedMph.toFixed(1)} mph</p>
|
|
)}
|
|
{application.spreaderSetting && (
|
|
<p>• Spreader Setting: {application.spreaderSetting}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
{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 className="flex gap-2 mt-2">
|
|
{application.status === 'planned' && (
|
|
<>
|
|
<button
|
|
onClick={() => handleExecuteApplication(application)}
|
|
className="p-1 text-green-600 hover:text-green-800 hover:bg-green-50 rounded"
|
|
title="Execute application"
|
|
>
|
|
<PlayIcon className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleEditPlan(application.id)}
|
|
className="p-1 text-blue-600 hover:text-blue-800 hover:bg-blue-50 rounded"
|
|
title="Edit plan"
|
|
>
|
|
<PencilIcon className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeletePlan(application.id, `${application.propertyName} - ${application.sectionName}`)}
|
|
className="p-1 text-red-600 hover:text-red-800 hover:bg-red-50 rounded"
|
|
title="Delete plan"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
{application.status === 'completed' && (
|
|
<>
|
|
<button
|
|
onClick={() => handleViewApplication(application)}
|
|
className="p-1 text-indigo-600 hover:text-indigo-800 hover:bg-indigo-50 rounded"
|
|
title="View completed application"
|
|
>
|
|
<EyeIcon className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleArchiveApplication(application.id)}
|
|
className="p-1 text-gray-600 hover:text-gray-800 hover:bg-gray-50 rounded"
|
|
title="Archive application"
|
|
>
|
|
<ArchiveBoxIcon className="h-4 w-4" />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Plan Application Modal */}
|
|
{showPlanForm && (
|
|
<ApplicationPlanModal
|
|
onClose={() => {
|
|
setShowPlanForm(false);
|
|
setEditingPlan(null);
|
|
setSelectedPropertyDetails(null);
|
|
}}
|
|
properties={properties}
|
|
products={products}
|
|
equipment={equipment}
|
|
nozzles={nozzles}
|
|
selectedPropertyDetails={selectedPropertyDetails}
|
|
onPropertySelect={fetchPropertyDetails}
|
|
editingPlan={editingPlan}
|
|
onSubmit={async (planData) => {
|
|
try {
|
|
if (editingPlan) {
|
|
// Edit existing plan - backend now supports multiple areas natively
|
|
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
|
|
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
|
|
|
|
// Calculate total area for all selected areas
|
|
const totalAreaSquareFeet = planData.selectedAreas.reduce((total, areaId) => {
|
|
const area = selectedPropertyDetails.sections.find(s => s.id === areaId);
|
|
return total + (area?.area || 0);
|
|
}, 0);
|
|
|
|
const planPayload = {
|
|
lawnSectionIds: planData.selectedAreas.map(id => parseInt(id)), // Send multiple areas
|
|
equipmentId: parseInt(planData.equipmentId),
|
|
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
|
|
plannedDate: planData.plannedDate || new Date().toISOString().split('T')[0],
|
|
notes: planData.notes || '',
|
|
areaSquareFeet: totalAreaSquareFeet,
|
|
equipment: {
|
|
id: selectedEquipment?.id,
|
|
categoryName: selectedEquipment?.categoryName,
|
|
tankSizeGallons: selectedEquipment?.tankSizeGallons,
|
|
pumpGpm: selectedEquipment?.pumpGpm,
|
|
sprayWidthFeet: selectedEquipment?.sprayWidthFeet,
|
|
capacityLbs: selectedEquipment?.capacityLbs,
|
|
spreadWidth: selectedEquipment?.spreadWidth
|
|
},
|
|
...(planData.applicationType === 'liquid' && selectedNozzle && {
|
|
nozzle: {
|
|
id: selectedNozzle.id,
|
|
flowRateGpm: selectedNozzle.flowRateGpm,
|
|
sprayAngle: selectedNozzle.sprayAngle
|
|
}
|
|
}),
|
|
products: planData.applicationType === 'liquid'
|
|
? planData.selectedProducts.map(item => ({
|
|
...(item.product?.isShared
|
|
? { productId: parseInt(item.product.id) }
|
|
: { userProductId: parseInt(item.product.id) }
|
|
),
|
|
rateAmount: parseFloat(item.rateAmount || 1),
|
|
rateUnit: item.rateUnit || 'oz/1000 sq ft',
|
|
applicationType: planData.applicationType
|
|
}))
|
|
: [{
|
|
...(planData.selectedProduct?.isShared
|
|
? { productId: parseInt(planData.selectedProduct.id) }
|
|
: { userProductId: parseInt(planData.selectedProduct.id) }
|
|
),
|
|
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
|
|
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft',
|
|
applicationType: planData.applicationType
|
|
}]
|
|
};
|
|
|
|
await applicationsAPI.updatePlan(editingPlan.id, planPayload);
|
|
toast.success(`Application plan updated successfully for ${planData.selectedAreas.length} area(s)`);
|
|
} else {
|
|
// Create new plan - backend now supports multiple areas in single plan
|
|
const selectedEquipment = equipment.find(eq => eq.id === parseInt(planData.equipmentId));
|
|
const selectedNozzle = planData.nozzleId ? nozzles.find(n => n.id === parseInt(planData.nozzleId)) : null;
|
|
|
|
// Calculate total area for all selected areas
|
|
const totalAreaSquareFeet = planData.selectedAreas.reduce((total, areaId) => {
|
|
const area = selectedPropertyDetails.sections.find(s => s.id === areaId);
|
|
return total + (area?.area || 0);
|
|
}, 0);
|
|
|
|
const planPayload = {
|
|
lawnSectionIds: planData.selectedAreas.map(id => parseInt(id)), // Send multiple areas
|
|
equipmentId: parseInt(planData.equipmentId),
|
|
...(planData.applicationType === 'liquid' && planData.nozzleId && { nozzleId: parseInt(planData.nozzleId) }),
|
|
plannedDate: new Date().toISOString().split('T')[0],
|
|
notes: planData.notes || '',
|
|
areaSquareFeet: totalAreaSquareFeet,
|
|
equipment: {
|
|
id: selectedEquipment?.id,
|
|
categoryName: selectedEquipment?.categoryName,
|
|
tankSizeGallons: selectedEquipment?.tankSizeGallons,
|
|
pumpGpm: selectedEquipment?.pumpGpm,
|
|
sprayWidthFeet: selectedEquipment?.sprayWidthFeet,
|
|
capacityLbs: selectedEquipment?.capacityLbs,
|
|
spreadWidth: selectedEquipment?.spreadWidth
|
|
},
|
|
...(planData.applicationType === 'liquid' && selectedNozzle && {
|
|
nozzle: {
|
|
id: selectedNozzle.id,
|
|
flowRateGpm: selectedNozzle.flowRateGpm,
|
|
sprayAngle: selectedNozzle.sprayAngle
|
|
}
|
|
}),
|
|
products: planData.applicationType === 'liquid'
|
|
? planData.selectedProducts.map(item => ({
|
|
...(item.product?.isShared
|
|
? { productId: parseInt(item.product.id) }
|
|
: { userProductId: parseInt(item.product.id) }
|
|
),
|
|
rateAmount: parseFloat(item.rateAmount || 1),
|
|
rateUnit: item.rateUnit || 'oz/1000 sq ft',
|
|
applicationType: planData.applicationType
|
|
}))
|
|
: [{
|
|
...(planData.selectedProduct?.isShared
|
|
? { productId: parseInt(planData.selectedProduct.id) }
|
|
: { userProductId: parseInt(planData.selectedProduct.id) }
|
|
),
|
|
rateAmount: parseFloat(planData.selectedProduct?.customRateAmount || planData.selectedProduct?.rateAmount || 1),
|
|
rateUnit: planData.selectedProduct?.customRateUnit || planData.selectedProduct?.rateUnit || 'per 1000sqft',
|
|
applicationType: planData.applicationType
|
|
}]
|
|
};
|
|
|
|
await applicationsAPI.createPlan(planPayload);
|
|
toast.success(`Created application plan for ${planData.selectedAreas.length} area(s) successfully`);
|
|
}
|
|
|
|
setShowPlanForm(false);
|
|
setEditingPlan(null);
|
|
fetchApplications();
|
|
} catch (error) {
|
|
console.error('Failed to save application plan:', error);
|
|
toast.error('Failed to save application plan');
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Application Execution Modal */}
|
|
{showExecutionModal && executingApplication && (
|
|
<ApplicationExecutionModal
|
|
application={executingApplication}
|
|
propertyDetails={selectedPropertyDetails}
|
|
onClose={() => {
|
|
setShowExecutionModal(false);
|
|
setExecutingApplication(null);
|
|
}}
|
|
onComplete={() => {
|
|
fetchApplications();
|
|
setShowExecutionModal(false);
|
|
setExecutingApplication(null);
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Application View Modal */}
|
|
{showViewModal && viewingApplication && (
|
|
<ApplicationViewModal
|
|
application={viewingApplication}
|
|
propertyDetails={selectedPropertyDetails}
|
|
onClose={() => {
|
|
setShowViewModal(false);
|
|
setViewingApplication(null);
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Applications;
|