1652 lines
66 KiB
JavaScript
1652 lines
66 KiB
JavaScript
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
PlusIcon,
|
|
MagnifyingGlassIcon,
|
|
FunnelIcon,
|
|
BeakerIcon,
|
|
TrashIcon,
|
|
PencilIcon
|
|
} from '@heroicons/react/24/outline';
|
|
import { productsAPI, productSpreaderSettingsAPI, equipmentAPI } from '../../services/api';
|
|
import LoadingSpinner from '../../components/UI/LoadingSpinner';
|
|
import toast from 'react-hot-toast';
|
|
|
|
const Products = () => {
|
|
const [sharedProducts, setSharedProducts] = useState([]);
|
|
const [userProducts, setUserProducts] = useState([]);
|
|
const [categories, setCategories] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [selectedCategory, setSelectedCategory] = useState('');
|
|
const [selectedType, setSelectedType] = useState('');
|
|
const [activeTab, setActiveTab] = useState('shared'); // 'shared' or 'custom'
|
|
const [editingProduct, setEditingProduct] = useState(null);
|
|
const [showEditForm, setShowEditForm] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
const fetchData = async () => {
|
|
try {
|
|
setLoading(true);
|
|
const [productsResponse, categoriesResponse] = await Promise.all([
|
|
productsAPI.getAll({
|
|
category: selectedCategory,
|
|
type: selectedType,
|
|
search: searchTerm
|
|
}),
|
|
productsAPI.getCategories()
|
|
]);
|
|
|
|
setSharedProducts(productsResponse.data.data.sharedProducts || []);
|
|
setUserProducts(productsResponse.data.data.userProducts || []);
|
|
setCategories(categoriesResponse.data.data.categories || []);
|
|
} catch (error) {
|
|
console.error('Failed to fetch products:', error);
|
|
toast.error('Failed to load products');
|
|
setSharedProducts([]);
|
|
setUserProducts([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleSearch = (e) => {
|
|
setSearchTerm(e.target.value);
|
|
// Debounce search
|
|
setTimeout(() => {
|
|
fetchData();
|
|
}, 300);
|
|
};
|
|
|
|
const handleFilterChange = () => {
|
|
fetchData();
|
|
};
|
|
|
|
const handleCreateProduct = async (productData) => {
|
|
try {
|
|
// Create the product first
|
|
const response = await productsAPI.createUserProduct(productData);
|
|
const createdProduct = response.data.data.userProduct;
|
|
|
|
// Save spreader settings if any
|
|
if (productData.spreaderSettings && productData.spreaderSettings.length > 0) {
|
|
const settingPromises = productData.spreaderSettings.map(setting => {
|
|
const payload = {
|
|
userProductId: createdProduct.id,
|
|
settingValue: setting.settingValue,
|
|
rateDescription: setting.rateDescription || null,
|
|
notes: setting.notes && setting.notes.trim() ? setting.notes.trim() : null
|
|
};
|
|
|
|
// Use equipment-based approach if equipmentId is available
|
|
if (setting.equipmentId) {
|
|
payload.equipmentId = parseInt(setting.equipmentId);
|
|
} else {
|
|
// Fall back to legacy approach
|
|
payload.spreaderBrand = setting.spreaderBrand;
|
|
payload.spreaderModel = setting.spreaderModel || null;
|
|
}
|
|
|
|
return productSpreaderSettingsAPI.create(payload);
|
|
});
|
|
|
|
await Promise.all(settingPromises);
|
|
}
|
|
|
|
toast.success(`Custom product created successfully${productData.spreaderSettings?.length ? ` with ${productData.spreaderSettings.length} spreader setting(s)` : ''}!`);
|
|
setShowCreateForm(false);
|
|
fetchData();
|
|
} catch (error) {
|
|
console.error('Failed to create product:', error);
|
|
toast.error('Failed to create product');
|
|
}
|
|
};
|
|
|
|
const handleDeleteUserProduct = async (productId) => {
|
|
if (window.confirm('Are you sure you want to delete this custom product?')) {
|
|
try {
|
|
await productsAPI.deleteUserProduct(productId);
|
|
toast.success('Product deleted successfully');
|
|
fetchData();
|
|
} catch (error) {
|
|
console.error('Failed to delete product:', error);
|
|
toast.error('Failed to delete product');
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleEditProduct = (product) => {
|
|
setEditingProduct(product);
|
|
setShowEditForm(true);
|
|
};
|
|
|
|
const handleUpdateProduct = async (productData) => {
|
|
try {
|
|
await productsAPI.updateUserProduct(editingProduct.id, productData);
|
|
toast.success('Product updated successfully!');
|
|
setShowEditForm(false);
|
|
setEditingProduct(null);
|
|
fetchData();
|
|
} catch (error) {
|
|
console.error('Failed to update product:', error);
|
|
toast.error('Failed to update product');
|
|
}
|
|
};
|
|
|
|
const handleAddToMyProducts = async (sharedProduct) => {
|
|
try {
|
|
// Create a custom product based on the shared product
|
|
const productData = {
|
|
productId: sharedProduct.id, // Link to the shared product
|
|
customName: sharedProduct.name, // Use the shared product's name as default
|
|
brand: sharedProduct.brand,
|
|
categoryId: null, // Will use the shared product's category
|
|
productType: sharedProduct.productType,
|
|
activeIngredients: sharedProduct.activeIngredients,
|
|
description: sharedProduct.description,
|
|
// Set default rate if available
|
|
customRateAmount: sharedProduct.rates?.[0]?.rateAmount || null,
|
|
customRateUnit: sharedProduct.rates?.[0]?.rateUnit || (sharedProduct.productType === 'granular' ? 'lbs/1000 sq ft' : 'oz/1000 sq ft'),
|
|
notes: `Added from shared product: ${sharedProduct.name}`
|
|
};
|
|
|
|
const response = await productsAPI.createUserProduct(productData);
|
|
toast.success(`"${sharedProduct.name}" added to your products!`);
|
|
|
|
// Refresh the data to show the new custom product
|
|
fetchData();
|
|
} catch (error) {
|
|
console.error('Failed to add product to my products:', error);
|
|
toast.error('Failed to add product to your collection');
|
|
}
|
|
};
|
|
|
|
const ProductCard = ({ product, isUserProduct = false, onAddToMyProducts }) => (
|
|
<div className="card">
|
|
<div className="flex justify-between items-start mb-3">
|
|
<div className="flex items-start gap-3">
|
|
<div className="p-2 bg-green-100 rounded-lg">
|
|
<BeakerIcon className="h-5 w-5 text-green-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900">
|
|
{isUserProduct ? product.customName || product.baseProductName : product.name}
|
|
</h3>
|
|
{product.brand && (
|
|
<p className="text-sm text-gray-600">{product.brand}</p>
|
|
)}
|
|
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium mt-1 ${
|
|
product.productType === 'liquid'
|
|
? 'bg-blue-100 text-blue-800'
|
|
: product.productType === 'granular'
|
|
? 'bg-green-100 text-green-800'
|
|
: product.productType === 'seed'
|
|
? 'bg-yellow-100 text-yellow-800'
|
|
: 'bg-gray-100 text-gray-800'
|
|
}`}>
|
|
{product.productType ?
|
|
product.productType.charAt(0).toUpperCase() + product.productType.slice(1)
|
|
: 'Type not set'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{isUserProduct && (
|
|
<div className="flex gap-1">
|
|
<button
|
|
onClick={() => handleEditProduct(product)}
|
|
className="p-1 text-gray-400 hover:text-blue-600"
|
|
>
|
|
<PencilIcon className="h-4 w-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteUserProduct(product.id)}
|
|
className="p-1 text-gray-400 hover:text-red-600"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{product.categoryName && (
|
|
<p className="text-sm text-gray-500 mb-2">
|
|
Category: {product.categoryName}
|
|
</p>
|
|
)}
|
|
|
|
{/* Seed blend pretty display */}
|
|
{product.productType === 'seed' ? (
|
|
(()=>{
|
|
let blend = [];
|
|
// Shared product
|
|
if (Array.isArray(product.seedBlend) && product.seedBlend.length) {
|
|
blend = product.seedBlend;
|
|
} else if (product.activeIngredients) {
|
|
// User product stores blend in activeIngredients JSON
|
|
try {
|
|
const ai = typeof product.activeIngredients === 'string' ? JSON.parse(product.activeIngredients) : product.activeIngredients;
|
|
blend = ai?.seedBlend || [];
|
|
} catch { /* ignore */ }
|
|
}
|
|
return (
|
|
<div className="mb-3">
|
|
<p className="text-sm font-medium text-gray-900">Seed Blend:</p>
|
|
{blend.length === 0 ? (
|
|
<p className="text-sm text-gray-600">No blend details</p>
|
|
) : (
|
|
<div className="mt-1 flex flex-wrap gap-2">
|
|
{blend.map((b, idx)=> (
|
|
<span key={idx} className="px-2 py-1 rounded bg-gray-100 text-gray-800 text-xs">
|
|
{b.cultivar} — {parseFloat(b.percent || 0).toFixed(1)}%
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()
|
|
) : (
|
|
product.activeIngredients && (
|
|
<p className="text-sm text-gray-700 mb-3">
|
|
<strong>Active Ingredients:</strong> {product.activeIngredients}
|
|
</p>
|
|
)
|
|
)}
|
|
|
|
{/* Application Rates */}
|
|
{isUserProduct && (
|
|
(()=>{
|
|
if (product.productType === 'seed') {
|
|
try {
|
|
const ai = typeof product.activeIngredients === 'string' ? JSON.parse(product.activeIngredients) : product.activeIngredients;
|
|
const sr = ai?.seedRates;
|
|
if (sr?.new || sr?.overseed) {
|
|
return (
|
|
<div className="bg-gray-50 p-3 rounded-lg mb-3">
|
|
<p className="text-sm font-medium text-gray-900 mb-1">Your Rates:</p>
|
|
{sr.new && <p className="text-sm text-gray-700">New Lawn: {sr.new} {sr.unit || product.customRateUnit}</p>}
|
|
{sr.overseed && <p className="text-sm text-gray-700">Overseeding: {sr.overseed} {sr.unit || product.customRateUnit}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
} catch {}
|
|
}
|
|
if (product.customRateAmount) {
|
|
return (
|
|
<div className="bg-gray-50 p-3 rounded-lg mb-3">
|
|
<p className="text-sm font-medium text-gray-900">Your Rate:</p>
|
|
<p className="text-sm text-gray-700">{product.customRateAmount} {product.customRateUnit}</p>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})()
|
|
)}
|
|
|
|
{!isUserProduct && product.rates && product.rates.length > 0 && (
|
|
<div className="bg-gray-50 p-3 rounded-lg mb-3">
|
|
<p className="text-sm font-medium text-gray-900 mb-2">Application Rates:</p>
|
|
{product.productType === 'seed' ? (
|
|
(()=>{
|
|
const newRate = product.rates.find(r=> (r.applicationType||'').toLowerCase().includes('new'));
|
|
const overRate = product.rates.find(r=> (r.applicationType||'').toLowerCase().includes('over'));
|
|
return (
|
|
<>
|
|
{newRate && (
|
|
<div className="text-sm text-gray-700"><strong>New Lawn:</strong> {newRate.rateAmount} {newRate.rateUnit}</div>
|
|
)}
|
|
{overRate && (
|
|
<div className="text-sm text-gray-700"><strong>Overseeding:</strong> {overRate.rateAmount} {overRate.rateUnit}</div>
|
|
)}
|
|
{!newRate && !overRate && product.rates.slice(0,2).map((rate, index)=> (
|
|
<div key={index} className="text-sm text-gray-700"><strong>{rate.applicationType}:</strong> {rate.rateAmount} {rate.rateUnit}</div>
|
|
))}
|
|
</>
|
|
);
|
|
})()
|
|
) : (
|
|
product.rates.slice(0, 2).map((rate, index) => (
|
|
<div key={index} className="text-sm text-gray-700">
|
|
<strong>{rate.applicationType}:</strong> {rate.rateAmount} {rate.rateUnit}
|
|
</div>
|
|
))
|
|
)}
|
|
{product.rates.length > 2 && (
|
|
<p className="text-xs text-gray-500 mt-1">+{product.rates.length - 2} more rates</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{product.notes && (
|
|
<p className="text-sm text-gray-600 mt-2">
|
|
<strong>Notes:</strong> {product.notes}
|
|
</p>
|
|
)}
|
|
|
|
{!isUserProduct && (
|
|
<div className="mt-3 pt-3 border-t border-gray-200">
|
|
<button
|
|
onClick={() => onAddToMyProducts(product)}
|
|
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
|
|
>
|
|
Add to My Products →
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
// Avoid blowing away the modal while background loading occurs
|
|
if (loading && !showCreateForm && !showEditForm) {
|
|
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">Products</h1>
|
|
<p className="text-gray-600">Manage your lawn care products and application rates</p>
|
|
</div>
|
|
<button
|
|
onClick={() => setShowCreateForm(true)}
|
|
className="btn-primary flex items-center gap-2"
|
|
>
|
|
<PlusIcon className="h-5 w-5" />
|
|
Add Custom Product
|
|
</button>
|
|
</div>
|
|
|
|
{/* Search and Filters */}
|
|
<div className="card mb-6">
|
|
<div className="flex flex-wrap gap-4">
|
|
{/* Search */}
|
|
<div className="flex-1 min-w-64">
|
|
<div className="relative">
|
|
<MagnifyingGlassIcon className="h-5 w-5 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" />
|
|
<input
|
|
type="text"
|
|
className="input pl-10"
|
|
placeholder="Search products..."
|
|
value={searchTerm}
|
|
onChange={handleSearch}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Category Filter */}
|
|
<div className="min-w-48">
|
|
<select
|
|
className="input"
|
|
value={selectedCategory}
|
|
onChange={(e) => {
|
|
setSelectedCategory(e.target.value);
|
|
handleFilterChange();
|
|
}}
|
|
>
|
|
<option value="">All Categories</option>
|
|
{categories.map((category) => (
|
|
<option key={category.id} value={category.id}>
|
|
{category.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Type Filter */}
|
|
<div className="min-w-32">
|
|
<select
|
|
className="input"
|
|
value={selectedType}
|
|
onChange={(e) => {
|
|
setSelectedType(e.target.value);
|
|
handleFilterChange();
|
|
}}
|
|
>
|
|
<option value="">All Types</option>
|
|
<option value="liquid">Liquid</option>
|
|
<option value="granular">Granular</option>
|
|
<option value="seed">Seed</option>
|
|
<option value="powder">Powder</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<div className="mb-6">
|
|
<div className="border-b border-gray-200">
|
|
<nav className="-mb-px flex">
|
|
<button
|
|
onClick={() => setActiveTab('shared')}
|
|
className={`py-2 px-4 border-b-2 font-medium text-sm ${
|
|
activeTab === 'shared'
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
Shared Products ({sharedProducts.length})
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('custom')}
|
|
className={`py-2 px-4 border-b-2 font-medium text-sm ${
|
|
activeTab === 'custom'
|
|
? 'border-blue-500 text-blue-600'
|
|
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
}`}
|
|
>
|
|
My Custom Products ({userProducts.length})
|
|
</button>
|
|
</nav>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Products Grid */}
|
|
{activeTab === 'shared' ? (
|
|
sharedProducts.length === 0 ? (
|
|
<div className="card text-center py-12">
|
|
<BeakerIcon className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No Products Found</h3>
|
|
<p className="text-gray-600">Try adjusting your search or filters</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{sharedProducts.map((product) => (
|
|
<ProductCard
|
|
key={product.id}
|
|
product={product}
|
|
onAddToMyProducts={handleAddToMyProducts}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
) : (
|
|
userProducts.length === 0 ? (
|
|
<div className="card text-center py-12">
|
|
<BeakerIcon className="h-16 w-16 text-gray-300 mx-auto mb-4" />
|
|
<h3 className="text-lg font-medium text-gray-900 mb-2">No Custom Products Yet</h3>
|
|
<p className="text-gray-600 mb-6">Create your own products with custom rates and notes</p>
|
|
<button
|
|
onClick={() => setShowCreateForm(true)}
|
|
className="btn-primary"
|
|
>
|
|
Add Your First Product
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{userProducts.map((product) => (
|
|
<ProductCard key={product.id} product={product} isUserProduct={true} />
|
|
))}
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{/* Create Product Form Modal */}
|
|
{showCreateForm && (
|
|
<CreateProductModal
|
|
onSubmit={handleCreateProduct}
|
|
onCancel={() => setShowCreateForm(false)}
|
|
sharedProducts={sharedProducts}
|
|
categories={categories}
|
|
/>
|
|
)}
|
|
|
|
{/* Edit Product Form Modal */}
|
|
{showEditForm && editingProduct && (
|
|
<EditProductModal
|
|
product={editingProduct}
|
|
onSubmit={handleUpdateProduct}
|
|
onCancel={() => {
|
|
setShowEditForm(false);
|
|
setEditingProduct(null);
|
|
}}
|
|
sharedProducts={sharedProducts}
|
|
categories={categories}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Create Product Modal Component
|
|
const CreateProductModal = ({ onSubmit, onCancel, sharedProducts, categories }) => {
|
|
const [formData, setFormData] = useState({
|
|
productId: '',
|
|
customName: '',
|
|
categoryId: '',
|
|
productType: 'granular',
|
|
brand: '',
|
|
activeIngredients: '',
|
|
customRateAmount: '',
|
|
customRateUnit: 'lbs/1000 sq ft',
|
|
notes: ''
|
|
});
|
|
|
|
const [spreaderSettings, setSpreaderSettings] = useState([]);
|
|
const [seedBlend, setSeedBlend] = useState([]); // [{cultivar:'', percent:0}]
|
|
const [seedNewRate, setSeedNewRate] = useState('');
|
|
const [seedOverRate, setSeedOverRate] = useState('');
|
|
const [availableSpreaders, setAvailableSpreaders] = useState([]);
|
|
const [loadingSpreaders, setLoadingSpreaders] = useState(false);
|
|
const [newSpreaderSetting, setNewSpreaderSetting] = useState({
|
|
equipmentId: '',
|
|
settingValue: '',
|
|
rateDescription: '',
|
|
notes: ''
|
|
});
|
|
|
|
// Load user's spreader equipment
|
|
useEffect(() => {
|
|
const loadSpreaders = async () => {
|
|
setLoadingSpreaders(true);
|
|
try {
|
|
const response = await equipmentAPI.getSpreaders();
|
|
setAvailableSpreaders(response.data.data.spreaders || []);
|
|
} catch (error) {
|
|
console.error('Failed to load spreaders:', error);
|
|
toast.error('Failed to load spreader equipment');
|
|
} finally {
|
|
setLoadingSpreaders(false);
|
|
}
|
|
};
|
|
|
|
loadSpreaders();
|
|
}, []);
|
|
|
|
const addSpreaderSetting = () => {
|
|
if (!newSpreaderSetting.equipmentId || !newSpreaderSetting.settingValue) {
|
|
toast.error('Please select spreader equipment and enter setting value');
|
|
return;
|
|
}
|
|
|
|
const selectedSpreader = availableSpreaders.find(s => s.id === parseInt(newSpreaderSetting.equipmentId));
|
|
const settingWithSpreaderInfo = {
|
|
...newSpreaderSetting,
|
|
equipmentId: parseInt(newSpreaderSetting.equipmentId), // Convert to number
|
|
id: Date.now(),
|
|
equipmentName: selectedSpreader?.name,
|
|
equipmentManufacturer: selectedSpreader?.manufacturer,
|
|
equipmentModel: selectedSpreader?.model
|
|
};
|
|
|
|
setSpreaderSettings([...spreaderSettings, settingWithSpreaderInfo]);
|
|
setNewSpreaderSetting({
|
|
equipmentId: '',
|
|
settingValue: '',
|
|
rateDescription: '',
|
|
notes: ''
|
|
});
|
|
};
|
|
|
|
const removeSpreaderSetting = (id) => {
|
|
setSpreaderSettings(spreaderSettings.filter(setting => setting.id !== id));
|
|
};
|
|
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
|
|
if (!formData.productId && !formData.customName) {
|
|
toast.error('Please select a base product or enter a custom name');
|
|
return;
|
|
}
|
|
|
|
// If creating a completely custom product (no base product), require more fields
|
|
if (!formData.productId && (!formData.categoryId || !formData.productType)) {
|
|
toast.error('Please select a category and product type for custom products');
|
|
return;
|
|
}
|
|
|
|
const submitData = {
|
|
productId: formData.productId ? parseInt(formData.productId) : null,
|
|
customName: formData.customName || null,
|
|
customRateAmount: formData.customRateAmount ? parseFloat(formData.customRateAmount) : null,
|
|
customRateUnit: formData.customRateUnit || null,
|
|
notes: formData.notes || null,
|
|
// Advanced fields for custom products
|
|
brand: formData.brand || null,
|
|
categoryId: formData.categoryId ? parseInt(formData.categoryId) : null,
|
|
productType: formData.productType || null,
|
|
activeIngredients: formData.activeIngredients || null,
|
|
description: formData.description || null,
|
|
spreaderSettings: formData.productType === 'granular' ? spreaderSettings : []
|
|
};
|
|
|
|
// If seed, include blend and optional seed rates in activeIngredients JSON
|
|
if (formData.productType === 'seed') {
|
|
const payload = { seedBlend: Array.isArray(seedBlend) ? seedBlend : [] };
|
|
if (seedNewRate || seedOverRate) {
|
|
payload.seedRates = {
|
|
new: seedNewRate ? parseFloat(seedNewRate) : null,
|
|
overseed: seedOverRate ? parseFloat(seedOverRate) : null,
|
|
unit: formData.customRateUnit
|
|
};
|
|
}
|
|
submitData.activeIngredients = JSON.stringify(payload);
|
|
}
|
|
|
|
onSubmit(submitData);
|
|
};
|
|
|
|
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-lg max-h-[90vh] overflow-y-auto">
|
|
<h3 className="text-lg font-semibold mb-4">Add Custom Product</h3>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div>
|
|
<label className="label">Base Product (Optional)</label>
|
|
<select
|
|
className="input"
|
|
value={formData.productId}
|
|
onChange={(e) => setFormData({ ...formData, productId: e.target.value })}
|
|
>
|
|
<option value="">Select a base product...</option>
|
|
{sharedProducts.map((product) => (
|
|
<option key={product.id} value={product.id}>
|
|
{product.name} {product.brand && `- ${product.brand}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Choose from our database or create a completely custom product below
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Custom Name</label>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
value={formData.customName}
|
|
onChange={(e) => setFormData({ ...formData, customName: e.target.value })}
|
|
placeholder="e.g., My 24-0-11 Blend, Custom Herbicide Mix"
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-1">
|
|
Required if no base product selected. Used to identify this product.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Show additional fields when creating completely custom product */}
|
|
{!formData.productId && (
|
|
<>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Category *</label>
|
|
<select
|
|
className="input"
|
|
value={formData.categoryId}
|
|
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
|
|
required={!formData.productId}
|
|
>
|
|
<option value="">Select category...</option>
|
|
{categories.map((category) => (
|
|
<option key={category.id} value={category.id}>
|
|
{category.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Product Type *</label>
|
|
<select
|
|
className="input"
|
|
value={formData.productType}
|
|
onChange={(e) => setFormData({ ...formData, productType: e.target.value })}
|
|
>
|
|
<option value="granular">Granular</option>
|
|
<option value="liquid">Liquid</option>
|
|
<option value="seed">Seed</option>
|
|
<option value="powder">Powder</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{formData.productType === 'seed' && (
|
|
<>
|
|
<SeedBlendEditor value={seedBlend} onChange={setSeedBlend} />
|
|
<div className="grid grid-cols-2 gap-3 mt-3">
|
|
<div>
|
|
<label className="label text-xs">New Lawn Seeding Rate</label>
|
|
<input type="number" step="0.01" className="input" value={seedNewRate} onChange={(e)=> setSeedNewRate(e.target.value)} placeholder="e.g., 7" />
|
|
</div>
|
|
<div>
|
|
<label className="label text-xs">Overseeding Rate</label>
|
|
<input type="number" step="0.01" className="input" value={seedOverRate} onChange={(e)=> setSeedOverRate(e.target.value)} placeholder="e.g., 3" />
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div>
|
|
<label className="label">Brand</label>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
value={formData.brand}
|
|
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
|
|
placeholder="e.g., Scotts, Syngenta, Custom Mix"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Active Ingredients</label>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
value={formData.activeIngredients}
|
|
onChange={(e) => setFormData({ ...formData, activeIngredients: e.target.value })}
|
|
placeholder="e.g., 2,4-D 25%, Nitrogen 24%, Iron 2%"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Application Rate</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="input"
|
|
value={formData.customRateAmount}
|
|
onChange={(e) => setFormData({ ...formData, customRateAmount: e.target.value })}
|
|
placeholder="2.5"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Rate Unit</label>
|
|
<select
|
|
className="input"
|
|
value={formData.customRateUnit}
|
|
onChange={(e) => setFormData({ ...formData, customRateUnit: e.target.value })}
|
|
>
|
|
<option value="lbs/1000 sq ft">lbs/1000 sq ft</option>
|
|
<option value="oz/1000 sq ft">oz/1000 sq ft</option>
|
|
<option value="gal/acre">gal/acre</option>
|
|
<option value="fl oz/1000 sq ft">fl oz/1000 sq ft</option>
|
|
<option value="ml/1000 sq ft">ml/1000 sq ft</option>
|
|
<option value="tbsp/1000 sq ft">tbsp/1000 sq ft</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Spreader Settings for Granular Products */}
|
|
{formData.productType === 'granular' && (
|
|
<div className="border-t pt-4">
|
|
<h4 className="text-md font-semibold text-gray-900 mb-3">Spreader Settings</h4>
|
|
<p className="text-sm text-gray-600 mb-4">
|
|
Add spreader settings for different brands/models. This helps determine the correct spreader dial setting when applying this product.
|
|
</p>
|
|
|
|
{/* List existing spreader settings */}
|
|
{spreaderSettings.length > 0 && (
|
|
<div className="space-y-2 mb-4">
|
|
<label className="label">Added Settings:</label>
|
|
{spreaderSettings.map((setting) => (
|
|
<div key={setting.id} className="flex items-center justify-between bg-gray-50 p-3 rounded-lg">
|
|
<div className="flex-1">
|
|
<div className="font-medium">
|
|
{setting.equipmentName || `${setting.equipmentManufacturer} ${setting.equipmentModel}`.trim()} - Setting: {setting.settingValue}
|
|
</div>
|
|
{setting.rateDescription && (
|
|
<div className="text-sm text-gray-600">{setting.rateDescription}</div>
|
|
)}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => removeSpreaderSetting(setting.id)}
|
|
className="text-red-600 hover:text-red-800"
|
|
>
|
|
<TrashIcon className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add new spreader setting form */}
|
|
<div className="bg-blue-50 p-4 rounded-lg space-y-3">
|
|
<h5 className="text-sm font-semibold text-blue-900">Add Spreader Setting</h5>
|
|
{availableSpreaders.length === 0 ? (
|
|
<div className="text-sm text-blue-800 bg-blue-100 p-3 rounded">
|
|
<p>No spreader equipment found. Please add spreader equipment to your inventory first.</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div>
|
|
<label className="label text-xs">Select Spreader Equipment *</label>
|
|
<select
|
|
className="input text-sm"
|
|
value={newSpreaderSetting.equipmentId}
|
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, equipmentId: e.target.value })}
|
|
>
|
|
<option value="">Choose spreader...</option>
|
|
{availableSpreaders.map((spreader) => (
|
|
<option key={spreader.id} value={spreader.id}>
|
|
{spreader.name} {spreader.spreaderType && `(${spreader.spreaderType})`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</>
|
|
)}
|
|
{availableSpreaders.length > 0 && (
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="label text-xs">Setting Value *</label>
|
|
<input
|
|
type="text"
|
|
className="input text-sm"
|
|
value={newSpreaderSetting.settingValue}
|
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, settingValue: e.target.value })}
|
|
placeholder="#14, 4, 20, etc."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label text-xs">Rate Description</label>
|
|
<input
|
|
type="text"
|
|
className="input text-sm"
|
|
value={newSpreaderSetting.rateDescription}
|
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, rateDescription: e.target.value })}
|
|
placeholder="e.g., 1 lb N per 1000 sq ft"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{availableSpreaders.length > 0 && (
|
|
<div>
|
|
<label className="label text-xs">Notes (Optional)</label>
|
|
<textarea
|
|
className="input text-sm"
|
|
rows="2"
|
|
value={newSpreaderSetting.notes}
|
|
onChange={(e) => setNewSpreaderSetting({ ...newSpreaderSetting, notes: e.target.value })}
|
|
placeholder="Additional notes about this setting..."
|
|
/>
|
|
</div>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={addSpreaderSetting}
|
|
disabled={!newSpreaderSetting.equipmentId || !newSpreaderSetting.settingValue || availableSpreaders.length === 0}
|
|
className="btn-primary text-sm px-3 py-1 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Add Setting
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="label">Notes</label>
|
|
<textarea
|
|
className="input"
|
|
rows="3"
|
|
value={formData.notes}
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
placeholder="Special mixing instructions, storage notes, etc."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-3 pt-4">
|
|
<button type="submit" className="btn-primary flex-1">
|
|
Create Product
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="btn-secondary flex-1"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Edit Product Modal Component
|
|
const EditProductModal = ({ product, onSubmit, onCancel, sharedProducts, categories }) => {
|
|
const [formData, setFormData] = useState({
|
|
productId: product.baseProductId || '',
|
|
customName: product.customName || '',
|
|
customRateAmount: product.customRateAmount || '',
|
|
customRateUnit: product.customRateUnit || 'lbs/1000 sq ft',
|
|
notes: product.notes || '',
|
|
// Add fields for full editing capability
|
|
brand: product.brand || '',
|
|
categoryId: product.categoryId || '',
|
|
productType: product.productType || '',
|
|
activeIngredients: product.activeIngredients || '',
|
|
description: product.description || ''
|
|
});
|
|
const [editSeedBlend, setEditSeedBlend] = useState([]);
|
|
const [editSeedNewRate, setEditSeedNewRate] = useState('');
|
|
const [editSeedOverRate, setEditSeedOverRate] = useState('');
|
|
|
|
const [editMode, setEditMode] = useState('basic'); // 'basic' or 'advanced'
|
|
const [editSpreaderSettings, setEditSpreaderSettings] = useState([]);
|
|
const [availableEditSpreaders, setAvailableEditSpreaders] = useState([]);
|
|
const [loadingEditSpreaders, setLoadingEditSpreaders] = useState(false);
|
|
const [editingSettingIndex, setEditingSettingIndex] = useState(null);
|
|
const [newEditSpreaderSetting, setNewEditSpreaderSetting] = useState({
|
|
equipmentId: '',
|
|
settingValue: '',
|
|
rateDescription: '',
|
|
notes: ''
|
|
});
|
|
const [loadingSettings, setLoadingSettings] = useState(false);
|
|
|
|
// Load user's spreader equipment
|
|
useEffect(() => {
|
|
const loadEditSpreaders = async () => {
|
|
setLoadingEditSpreaders(true);
|
|
try {
|
|
const response = await equipmentAPI.getSpreaders();
|
|
setAvailableEditSpreaders(response.data.data.spreaders || []);
|
|
} catch (error) {
|
|
console.error('Failed to load spreaders:', error);
|
|
toast.error('Failed to load spreader equipment');
|
|
} finally {
|
|
setLoadingEditSpreaders(false);
|
|
}
|
|
};
|
|
|
|
loadEditSpreaders();
|
|
}, []);
|
|
|
|
// Load existing spreader settings when modal opens
|
|
useEffect(() => {
|
|
const loadSpreaderSettings = async () => {
|
|
if (product.productType === 'granular' && product.id) {
|
|
setLoadingSettings(true);
|
|
try {
|
|
const response = await fetch(`/api/product-spreader-settings/user-product/${product.id}`, {
|
|
headers: {
|
|
'Authorization': `Bearer ${localStorage.getItem('authToken')}`
|
|
}
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
console.log('Received spreader settings data:', data);
|
|
setEditSpreaderSettings(data.data?.settings || []);
|
|
} else {
|
|
console.error('Failed to fetch spreader settings:', response.status, response.statusText);
|
|
const errorText = await response.text();
|
|
console.error('Error response body:', errorText);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load spreader settings:', error);
|
|
} finally {
|
|
setLoadingSettings(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadSpreaderSettings();
|
|
}, [product.id, product.productType]);
|
|
|
|
// Parse seed data from activeIngredients when opening
|
|
useEffect(() => {
|
|
if (product.productType === 'seed') {
|
|
try {
|
|
const ai = typeof product.activeIngredients === 'string' ? JSON.parse(product.activeIngredients) : product.activeIngredients;
|
|
if (ai?.seedBlend) setEditSeedBlend(ai.seedBlend);
|
|
if (ai?.seedRates) {
|
|
setEditSeedNewRate(ai.seedRates.new || '');
|
|
setEditSeedOverRate(ai.seedRates.overseed || '');
|
|
if (ai.seedRates.unit && !formData.customRateUnit) {
|
|
setFormData(prev => ({ ...prev, customRateUnit: ai.seedRates.unit }));
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, []);
|
|
|
|
const handleSubmit = (e) => {
|
|
e.preventDefault();
|
|
|
|
if (editMode === 'basic') {
|
|
// Basic mode validation
|
|
if (!formData.productId && !formData.customName) {
|
|
toast.error('Please select a base product or enter a custom name');
|
|
return;
|
|
}
|
|
|
|
const submitData = {
|
|
productId: formData.productId ? parseInt(formData.productId) : null,
|
|
customName: formData.customName || null,
|
|
customRateAmount: formData.customRateAmount ? parseFloat(formData.customRateAmount) : null,
|
|
customRateUnit: formData.customRateUnit || null,
|
|
notes: formData.notes || null
|
|
};
|
|
|
|
onSubmit(submitData);
|
|
} else {
|
|
// Advanced mode validation and submission
|
|
if (!formData.customName) {
|
|
toast.error('Product name is required');
|
|
return;
|
|
}
|
|
if (!formData.categoryId) {
|
|
toast.error('Category is required');
|
|
return;
|
|
}
|
|
if (!formData.productType) {
|
|
toast.error('Product type is required');
|
|
return;
|
|
}
|
|
|
|
const submitData = {
|
|
productId: formData.productId ? parseInt(formData.productId) : null,
|
|
customName: formData.customName,
|
|
customRateAmount: formData.customRateAmount ? parseFloat(formData.customRateAmount) : null,
|
|
customRateUnit: formData.customRateUnit || null,
|
|
notes: formData.notes && formData.notes.trim() ? formData.notes.trim() : null,
|
|
// Additional fields for advanced mode
|
|
brand: formData.brand || null,
|
|
categoryId: formData.categoryId ? parseInt(formData.categoryId) : null,
|
|
productType: formData.productType || null,
|
|
activeIngredients: formData.productType === 'seed' ? JSON.stringify({
|
|
seedBlend: editSeedBlend,
|
|
seedRates: (editSeedNewRate || editSeedOverRate) ? {
|
|
new: editSeedNewRate ? parseFloat(editSeedNewRate) : null,
|
|
overseed: editSeedOverRate ? parseFloat(editSeedOverRate) : null,
|
|
unit: formData.customRateUnit
|
|
} : undefined
|
|
}) : (formData.activeIngredients || null),
|
|
description: formData.description || null,
|
|
isAdvancedEdit: true, // Flag to indicate this is an advanced edit
|
|
// Include spreader settings for granular products
|
|
...(formData.productType === 'granular' && editSpreaderSettings.length > 0 && {
|
|
spreaderSettings: editSpreaderSettings.map(setting => ({
|
|
equipmentId: setting.equipmentId || null,
|
|
// Legacy fields for backward compatibility
|
|
spreaderBrand: setting.spreaderBrand || null,
|
|
spreaderModel: setting.spreaderModel && setting.spreaderModel.trim() ? setting.spreaderModel.trim() : null,
|
|
settingValue: setting.settingValue,
|
|
rateDescription: setting.rateDescription && setting.rateDescription.trim() ? setting.rateDescription.trim() : null,
|
|
notes: setting.notes && setting.notes.trim() ? setting.notes.trim() : null
|
|
}))
|
|
})
|
|
};
|
|
|
|
onSubmit(submitData);
|
|
}
|
|
};
|
|
|
|
// Handler functions for edit spreader settings
|
|
const handleAddEditSpreaderSetting = () => {
|
|
if (!newEditSpreaderSetting.equipmentId || !newEditSpreaderSetting.settingValue.trim()) {
|
|
return;
|
|
}
|
|
|
|
const selectedSpreader = availableEditSpreaders.find(s => s.id === parseInt(newEditSpreaderSetting.equipmentId));
|
|
const settingWithSpreaderInfo = {
|
|
...newEditSpreaderSetting,
|
|
equipmentId: parseInt(newEditSpreaderSetting.equipmentId), // Convert to number
|
|
equipmentName: selectedSpreader?.name,
|
|
equipmentManufacturer: selectedSpreader?.manufacturer,
|
|
equipmentModel: selectedSpreader?.model
|
|
};
|
|
|
|
setEditSpreaderSettings([...editSpreaderSettings, settingWithSpreaderInfo]);
|
|
setNewEditSpreaderSetting({
|
|
equipmentId: '',
|
|
settingValue: '',
|
|
rateDescription: '',
|
|
notes: ''
|
|
});
|
|
};
|
|
|
|
const handleRemoveExistingSpreaderSetting = (index) => {
|
|
const updated = editSpreaderSettings.filter((_, i) => i !== index);
|
|
setEditSpreaderSettings(updated);
|
|
};
|
|
|
|
const handleStartEditSetting = (index) => {
|
|
setEditingSettingIndex(index);
|
|
};
|
|
|
|
const handleCancelEditSetting = () => {
|
|
setEditingSettingIndex(null);
|
|
};
|
|
|
|
const handleSaveEditSetting = (index, updatedSetting) => {
|
|
const updated = [...editSpreaderSettings];
|
|
updated[index] = updatedSetting;
|
|
setEditSpreaderSettings(updated);
|
|
setEditingSettingIndex(null);
|
|
};
|
|
|
|
// Inline edit component for existing spreader settings
|
|
const EditSpreaderSettingForm = ({ setting, availableSpreaders, onSave, onCancel }) => {
|
|
const [editValues, setEditValues] = useState({
|
|
equipmentId: setting.equipmentId || '',
|
|
settingValue: setting.settingValue || '',
|
|
rateDescription: setting.rateDescription || '',
|
|
notes: setting.notes || ''
|
|
});
|
|
|
|
const handleSave = () => {
|
|
if (!editValues.settingValue.trim()) {
|
|
return;
|
|
}
|
|
|
|
const selectedSpreader = availableSpreaders.find(s => s.id.toString() === editValues.equipmentId);
|
|
|
|
const updatedSetting = {
|
|
...setting,
|
|
equipmentId: editValues.equipmentId ? parseInt(editValues.equipmentId) : null,
|
|
equipmentName: selectedSpreader?.name || setting.equipmentName,
|
|
equipmentManufacturer: selectedSpreader?.manufacturer || setting.equipmentManufacturer,
|
|
equipmentModel: selectedSpreader?.model || setting.equipmentModel,
|
|
settingValue: editValues.settingValue.trim(),
|
|
rateDescription: editValues.rateDescription.trim() || null,
|
|
notes: editValues.notes.trim() || null
|
|
};
|
|
|
|
onSave(updatedSetting);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Spreader</label>
|
|
<select
|
|
value={editValues.equipmentId}
|
|
onChange={(e) => setEditValues({ ...editValues, equipmentId: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
>
|
|
<option value="">Select equipment...</option>
|
|
{availableSpreaders.map(spreader => (
|
|
<option key={spreader.id} value={spreader.id}>
|
|
{spreader.name} ({spreader.manufacturer} {spreader.model})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Setting Value *</label>
|
|
<input
|
|
type="text"
|
|
value={editValues.settingValue}
|
|
onChange={(e) => setEditValues({ ...editValues, settingValue: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
placeholder="e.g., 3.5, H, 15"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Rate Description</label>
|
|
<input
|
|
type="text"
|
|
value={editValues.rateDescription}
|
|
onChange={(e) => setEditValues({ ...editValues, rateDescription: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
placeholder="e.g., 1 lb nitrogen per 1000 sq ft"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
|
<input
|
|
type="text"
|
|
value={editValues.notes}
|
|
onChange={(e) => setEditValues({ ...editValues, notes: e.target.value })}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
|
|
placeholder="Additional notes..."
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={handleSave}
|
|
disabled={!editValues.settingValue.trim()}
|
|
className="px-3 py-1 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:bg-gray-300"
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="px-3 py-1 bg-gray-500 text-white rounded text-sm hover:bg-gray-600"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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-lg max-h-[90vh] overflow-y-auto">
|
|
<h3 className="text-lg font-semibold mb-4">Edit Product</h3>
|
|
|
|
{/* Edit Mode Toggle */}
|
|
<div className="mb-4">
|
|
<div className="flex bg-gray-100 rounded-lg p-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditMode('basic')}
|
|
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
|
editMode === 'basic'
|
|
? 'bg-white text-gray-900 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
Basic Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditMode('advanced')}
|
|
className={`flex-1 py-2 px-4 rounded-md text-sm font-medium transition-colors ${
|
|
editMode === 'advanced'
|
|
? 'bg-white text-gray-900 shadow-sm'
|
|
: 'text-gray-600 hover:text-gray-900'
|
|
}`}
|
|
>
|
|
Advanced Edit
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
{editMode === 'basic' ? (
|
|
// Basic editing - existing functionality
|
|
<>
|
|
<div>
|
|
<label className="label">Base Product (Optional)</label>
|
|
<select
|
|
className="input"
|
|
value={formData.productId}
|
|
onChange={(e) => setFormData({ ...formData, productId: e.target.value })}
|
|
>
|
|
<option value="">Select a base product...</option>
|
|
{sharedProducts.map((sharedProduct) => (
|
|
<option key={sharedProduct.id} value={sharedProduct.id}>
|
|
{sharedProduct.name} {sharedProduct.brand && `- ${sharedProduct.brand}`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Custom Name</label>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
value={formData.customName}
|
|
onChange={(e) => setFormData({ ...formData, customName: e.target.value })}
|
|
placeholder="e.g., My 24-0-11 Blend, Custom Herbicide Mix"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Application Rate</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="input"
|
|
value={formData.customRateAmount}
|
|
onChange={(e) => setFormData({ ...formData, customRateAmount: e.target.value })}
|
|
placeholder="2.5"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Rate Unit</label>
|
|
<select
|
|
className="input"
|
|
value={formData.customRateUnit}
|
|
onChange={(e) => setFormData({ ...formData, customRateUnit: e.target.value })}
|
|
>
|
|
<option value="lbs/1000 sq ft">lbs/1000 sq ft</option>
|
|
<option value="oz/1000 sq ft">oz/1000 sq ft</option>
|
|
<option value="gal/acre">gal/acre</option>
|
|
<option value="fl oz/1000 sq ft">fl oz/1000 sq ft</option>
|
|
<option value="ml/1000 sq ft">ml/1000 sq ft</option>
|
|
<option value="tbsp/1000 sq ft">tbsp/1000 sq ft</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Notes</label>
|
|
<textarea
|
|
className="input"
|
|
rows="3"
|
|
value={formData.notes}
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
placeholder="Special mixing instructions, storage notes, etc."
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
// Advanced editing - all fields
|
|
<>
|
|
<div>
|
|
<label className="label">Product Name *</label>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
value={formData.customName}
|
|
onChange={(e) => setFormData({ ...formData, customName: e.target.value })}
|
|
placeholder="Product name"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Brand</label>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
value={formData.brand}
|
|
onChange={(e) => setFormData({ ...formData, brand: e.target.value })}
|
|
placeholder="Manufacturer or brand name"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Category *</label>
|
|
<select
|
|
className="input"
|
|
value={formData.categoryId}
|
|
onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })}
|
|
required
|
|
>
|
|
<option value="">Select category...</option>
|
|
{categories.map((category) => (
|
|
<option key={category.id} value={category.id}>
|
|
{category.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<label className="label">Product Type *</label>
|
|
<select
|
|
className="input"
|
|
value={formData.productType}
|
|
onChange={(e) => setFormData({ ...formData, productType: e.target.value })}
|
|
required
|
|
>
|
|
<option value="">Select type...</option>
|
|
<option value="liquid">Liquid</option>
|
|
<option value="granular">Granular</option>
|
|
<option value="seed">Seed</option>
|
|
<option value="powder">Powder</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
{formData.productType === 'seed' ? (
|
|
<>
|
|
<SeedBlendEditor value={editSeedBlend} onChange={setEditSeedBlend} />
|
|
<div className="grid grid-cols-2 gap-3 mt-3">
|
|
<div>
|
|
<label className="label text-xs">New Lawn Seeding Rate</label>
|
|
<input type="number" step="0.01" className="input" value={editSeedNewRate} onChange={(e)=> setEditSeedNewRate(e.target.value)} placeholder="e.g., 7" />
|
|
</div>
|
|
<div>
|
|
<label className="label text-xs">Overseeding Rate</label>
|
|
<input type="number" step="0.01" className="input" value={editSeedOverRate} onChange={(e)=> setEditSeedOverRate(e.target.value)} placeholder="e.g., 3" />
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div>
|
|
<label className="label">Active Ingredients</label>
|
|
<input
|
|
type="text"
|
|
className="input"
|
|
value={formData.activeIngredients}
|
|
onChange={(e) => setFormData({ ...formData, activeIngredients: e.target.value })}
|
|
placeholder="e.g., 2,4-D 38.3%, Dicamba 2.77%"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label className="label">Description</label>
|
|
<textarea
|
|
className="input"
|
|
rows="3"
|
|
value={formData.description}
|
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
|
placeholder="Product description and usage information"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="label">Default Application Rate</label>
|
|
<input
|
|
type="number"
|
|
step="0.01"
|
|
className="input"
|
|
value={formData.customRateAmount}
|
|
onChange={(e) => setFormData({ ...formData, customRateAmount: e.target.value })}
|
|
placeholder="2.5"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="label">Rate Unit</label>
|
|
<select
|
|
className="input"
|
|
value={formData.customRateUnit}
|
|
onChange={(e) => setFormData({ ...formData, customRateUnit: e.target.value })}
|
|
>
|
|
<option value="lbs/1000 sq ft">lbs/1000 sq ft</option>
|
|
<option value="oz/1000 sq ft">oz/1000 sq ft</option>
|
|
<option value="gal/acre">gal/acre</option>
|
|
<option value="fl oz/1000 sq ft">fl oz/1000 sq ft</option>
|
|
<option value="ml/1000 sq ft">ml/1000 sq ft</option>
|
|
<option value="tbsp/1000 sq ft">tbsp/1000 sq ft</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="label">Notes</label>
|
|
<textarea
|
|
className="input"
|
|
rows="3"
|
|
value={formData.notes}
|
|
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
|
placeholder="Special mixing instructions, storage notes, etc."
|
|
/>
|
|
</div>
|
|
|
|
{formData.productType === 'granular' && (
|
|
<div>
|
|
<label className="label">Spreader Settings</label>
|
|
<div className="border border-gray-200 rounded-lg p-4 space-y-3">
|
|
{/* Existing spreader settings */}
|
|
{editSpreaderSettings.length > 0 && (
|
|
<div className="space-y-2">
|
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Current Settings:</h4>
|
|
{editSpreaderSettings.map((setting, index) => (
|
|
<div key={index} className="bg-gray-50 p-3 rounded border text-sm">
|
|
{editingSettingIndex === index ? (
|
|
<EditSpreaderSettingForm
|
|
setting={setting}
|
|
availableSpreaders={availableEditSpreaders}
|
|
onSave={(updatedSetting) => handleSaveEditSetting(index, updatedSetting)}
|
|
onCancel={handleCancelEditSetting}
|
|
/>
|
|
) : (
|
|
<div className="flex justify-between items-start">
|
|
<div>
|
|
<div><strong>{setting.equipmentName || `${setting.spreaderBrand}${setting.spreaderModel ? ` (${setting.spreaderModel})` : ''}`}</strong></div>
|
|
<div>Setting: <span className="font-medium">{setting.settingValue}</span></div>
|
|
{setting.rateDescription && <div className="text-gray-600">{setting.rateDescription}</div>}
|
|
{setting.notes && <div className="text-gray-600 italic">{setting.notes}</div>}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleStartEditSetting(index)}
|
|
className="text-blue-600 hover:text-blue-800 text-sm"
|
|
>
|
|
Edit
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleRemoveExistingSpreaderSetting(index)}
|
|
className="text-red-600 hover:text-red-800 text-sm"
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Add new spreader setting */}
|
|
<div>
|
|
<h4 className="text-sm font-medium text-gray-700 mb-2">Add New Setting:</h4>
|
|
{availableEditSpreaders.length === 0 ? (
|
|
<div className="text-sm text-blue-800 bg-blue-100 p-3 rounded mb-3">
|
|
<p>No spreader equipment found. Please add spreader equipment to your inventory first.</p>
|
|
</div>
|
|
) : (
|
|
<div className="mb-3">
|
|
<select
|
|
className="input text-sm"
|
|
value={newEditSpreaderSetting.equipmentId}
|
|
onChange={(e) => setNewEditSpreaderSetting({ ...newEditSpreaderSetting, equipmentId: e.target.value })}
|
|
>
|
|
<option value="">Choose spreader...</option>
|
|
{availableEditSpreaders.map((spreader) => (
|
|
<option key={spreader.id} value={spreader.id}>
|
|
{spreader.name} {spreader.spreaderType && `(${spreader.spreaderType})`}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)}
|
|
{availableEditSpreaders.length > 0 && (
|
|
<>
|
|
<div className="mt-3 grid grid-cols-2 gap-3">
|
|
<div>
|
|
<input
|
|
type="text"
|
|
className="input text-sm"
|
|
placeholder="Setting (e.g., #14, 2.5)"
|
|
value={newEditSpreaderSetting.settingValue}
|
|
onChange={(e) => setNewEditSpreaderSetting({ ...newEditSpreaderSetting, settingValue: e.target.value })}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<input
|
|
type="text"
|
|
className="input text-sm"
|
|
placeholder="Rate description (optional)"
|
|
value={newEditSpreaderSetting.rateDescription}
|
|
onChange={(e) => setNewEditSpreaderSetting({ ...newEditSpreaderSetting, rateDescription: e.target.value })}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="mt-3">
|
|
<textarea
|
|
className="input text-sm"
|
|
rows="2"
|
|
placeholder="Notes (optional)"
|
|
value={newEditSpreaderSetting.notes}
|
|
onChange={(e) => setNewEditSpreaderSetting({ ...newEditSpreaderSetting, notes: e.target.value })}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={handleAddEditSpreaderSetting}
|
|
disabled={!newEditSpreaderSetting.equipmentId || !newEditSpreaderSetting.settingValue.trim() || availableEditSpreaders.length === 0}
|
|
className="mt-3 px-3 py-1.5 bg-blue-600 text-white rounded text-sm hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
>
|
|
Add Setting
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<div className="flex gap-3 pt-4">
|
|
<button type="submit" className="btn-primary flex-1">
|
|
Update Product
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="btn-secondary flex-1"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Editor component for seed blends
|
|
function SeedBlendEditor({ value = [], onChange }) {
|
|
const [rows, setRows] = React.useState(value || []);
|
|
React.useEffect(()=>{ onChange && onChange(rows); }, [rows]);
|
|
const addRow = () => setRows([...(rows||[]), { cultivar: '', percent: '' }]);
|
|
const updateRow = (i, field, v) => setRows((rows||[]).map((r,idx)=> idx===i? { ...r, [field]: v } : r));
|
|
const removeRow = (i) => setRows((rows||[]).filter((_,idx)=> idx!==i));
|
|
const total = (rows||[]).reduce((s,r)=> s + (parseFloat(r.percent)||0), 0);
|
|
return (
|
|
<div className="border rounded-lg p-3">
|
|
<div className="flex justify-between items-center mb-2">
|
|
<label className="label m-0">Seed Blend</label>
|
|
<button type="button" className="text-sm text-blue-600" onClick={addRow}>Add Cultivar</button>
|
|
</div>
|
|
{(rows||[]).length === 0 ? (
|
|
<div className="text-sm text-gray-500">No cultivars added</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{(rows||[]).map((row, i) => (
|
|
<div key={i} className="grid grid-cols-6 gap-2 items-center">
|
|
<input className="input col-span-4" placeholder="Cultivar name" value={row.cultivar} onChange={(e)=> updateRow(i,'cultivar', e.target.value)} />
|
|
<div className="col-span-1 flex items-center gap-1">
|
|
<input type="number" step="0.1" className="input" placeholder="%" value={row.percent} onChange={(e)=> updateRow(i,'percent', e.target.value)} />
|
|
</div>
|
|
<button type="button" className="text-red-600" onClick={()=> removeRow(i)}>Remove</button>
|
|
</div>
|
|
))}
|
|
<div className={`text-xs ${Math.abs(total-100) < 0.01 ? 'text-green-700' : 'text-gray-600'}`}>Total: {total.toFixed(1)}%</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default Products;
|