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 }) => (
{isUserProduct ? product.customName || product.baseProductName : product.name}
{product.brand && (
{product.brand}
)}
{product.productType ?
product.productType.charAt(0).toUpperCase() + product.productType.slice(1)
: 'Type not set'}
{isUserProduct && (
)}
{product.categoryName && (
Category: {product.categoryName}
)}
{/* 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 (
Seed Blend:
{blend.length === 0 ? (
No blend details
) : (
{blend.map((b, idx)=> (
{b.cultivar} — {parseFloat(b.percent || 0).toFixed(1)}%
))}
)}
);
})()
) : (
product.activeIngredients && (
Active Ingredients: {product.activeIngredients}
)
)}
{/* 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 (
Your Rates:
{sr.new &&
New Lawn: {sr.new} {sr.unit || product.customRateUnit}
}
{sr.overseed &&
Overseeding: {sr.overseed} {sr.unit || product.customRateUnit}
}
);
}
} catch {}
}
if (product.customRateAmount) {
return (
Your Rate:
{product.customRateAmount} {product.customRateUnit}
);
}
return null;
})()
)}
{!isUserProduct && product.rates && product.rates.length > 0 && (
Application Rates:
{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 && (
New Lawn: {newRate.rateAmount} {newRate.rateUnit}
)}
{overRate && (
Overseeding: {overRate.rateAmount} {overRate.rateUnit}
)}
{!newRate && !overRate && product.rates.slice(0,2).map((rate, index)=> (
{rate.applicationType}: {rate.rateAmount} {rate.rateUnit}
))}
>
);
})()
) : (
product.rates.slice(0, 2).map((rate, index) => (
{rate.applicationType}: {rate.rateAmount} {rate.rateUnit}
))
)}
{product.rates.length > 2 && (
+{product.rates.length - 2} more rates
)}
)}
{product.notes && (
Notes: {product.notes}
)}
{!isUserProduct && (
)}
);
// Avoid blowing away the modal while background loading occurs
if (loading && !showCreateForm && !showEditForm) {
return (
);
}
return (
Products
Manage your lawn care products and application rates
{/* Search and Filters */}
{/* Search */}
{/* Category Filter */}
{/* Type Filter */}
{/* Tabs */}
{/* Products Grid */}
{activeTab === 'shared' ? (
sharedProducts.length === 0 ? (
No Products Found
Try adjusting your search or filters
) : (
{sharedProducts.map((product) => (
))}
)
) : (
userProducts.length === 0 ? (
No Custom Products Yet
Create your own products with custom rates and notes
) : (
{userProducts.map((product) => (
))}
)
)}
{/* Create Product Form Modal */}
{showCreateForm && (
setShowCreateForm(false)}
sharedProducts={sharedProducts}
categories={categories}
/>
)}
{/* Edit Product Form Modal */}
{showEditForm && editingProduct && (
{
setShowEditForm(false);
setEditingProduct(null);
}}
sharedProducts={sharedProducts}
categories={categories}
/>
)}
);
};
// 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 (
);
};
// 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 (
);
};
return (
Edit Product
{/* Edit Mode Toggle */}
);
};
// 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 (
{(rows||[]).length === 0 ? (
No cultivars added
) : (
{(rows||[]).map((row, i) => (
))}
Total: {total.toFixed(1)}%
)}
);
}
export default Products;