remove intermediate grocery table and add related_products feature
This commit is contained in:
@@ -5,7 +5,6 @@ import ShopList from './components/ShopList';
|
||||
import ProductList from './components/ProductList';
|
||||
import ShoppingEventList from './components/ShoppingEventList';
|
||||
import BrandList from './components/BrandList';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import GroceryCategoryList from './components/GroceryCategoryList';
|
||||
import ImportExportModal from './components/ImportExportModal';
|
||||
|
||||
@@ -71,16 +70,6 @@ function Navigation({ onImportExportClick }: { onImportExportClick: () => void }
|
||||
>
|
||||
Brands
|
||||
</Link>
|
||||
<Link
|
||||
to="/groceries"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/groceries')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Groceries
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
@@ -130,7 +119,6 @@ function App() {
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/products" element={<ProductList />} />
|
||||
<Route path="/brands" element={<BrandList />} />
|
||||
<Route path="/groceries" element={<GroceryList />} />
|
||||
<Route path="/categories" element={<GroceryCategoryList />} />
|
||||
</Routes>
|
||||
</div>
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Grocery, GroceryCreate, GroceryCategory } from '../types';
|
||||
import { groceryApi, groceryCategoryApi } from '../services/api';
|
||||
|
||||
interface AddGroceryModalProps {
|
||||
grocery?: Grocery | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) => {
|
||||
const [formData, setFormData] = useState<GroceryCreate>({
|
||||
name: '',
|
||||
category_id: 0
|
||||
});
|
||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const isEditMode = Boolean(grocery);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
if (grocery) {
|
||||
setFormData({
|
||||
name: grocery.name,
|
||||
category_id: grocery.category_id
|
||||
});
|
||||
}
|
||||
}, [grocery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
event.preventDefault();
|
||||
if (formData.name.trim() && formData.category_id > 0) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [formData, loading, onClose]);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await groceryCategoryApi.getAll();
|
||||
setCategories(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
setMessage('Error loading categories. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
if (isEditMode && grocery) {
|
||||
await groceryApi.update(grocery.id, formData);
|
||||
setMessage('Grocery updated successfully!');
|
||||
} else {
|
||||
await groceryApi.create(formData);
|
||||
setMessage('Grocery created successfully!');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving grocery:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{isEditMode ? 'Edit Grocery' : 'Add New Grocery'}
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 px-4 py-3 rounded ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Grocery Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter grocery name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
value={formData.category_id}
|
||||
onChange={(e) => setFormData({...formData, category_id: parseInt(e.target.value)})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
>
|
||||
<option value={0}>Select a category</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || formData.category_id === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading
|
||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||
: (isEditMode ? 'Update Grocery' : 'Create Grocery')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddGroceryModal;
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { productApi, brandApi, groceryApi } from '../services/api';
|
||||
import { Product, Brand, Grocery } from '../types';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { productApi, brandApi, groceryCategoryApi } from '../services/api';
|
||||
import { Product, Brand, GroceryCategory } from '../types';
|
||||
|
||||
interface AddProductModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -11,7 +11,7 @@ interface AddProductModalProps {
|
||||
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
grocery_id?: number;
|
||||
category_id?: number;
|
||||
brand_id?: number;
|
||||
organic: boolean;
|
||||
weight?: number;
|
||||
@@ -21,24 +21,24 @@ interface ProductFormData {
|
||||
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
|
||||
const [formData, setFormData] = useState<ProductFormData>({
|
||||
name: '',
|
||||
grocery_id: undefined,
|
||||
category_id: undefined,
|
||||
brand_id: undefined,
|
||||
organic: false,
|
||||
weight: undefined,
|
||||
weight_unit: 'piece'
|
||||
});
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
||||
|
||||
// Fetch brands and groceries when modal opens
|
||||
// Fetch brands and categories when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchBrands();
|
||||
fetchGroceries();
|
||||
fetchCategories();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -51,12 +51,12 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
const response = await groceryCategoryApi.getAll();
|
||||
setCategories(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching groceries:', err);
|
||||
console.error('Error fetching categories:', err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
if (editProduct) {
|
||||
setFormData({
|
||||
name: editProduct.name,
|
||||
grocery_id: editProduct.grocery_id,
|
||||
category_id: editProduct.category_id,
|
||||
brand_id: editProduct.brand_id,
|
||||
organic: editProduct.organic,
|
||||
weight: editProduct.weight,
|
||||
@@ -75,7 +75,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
// Reset form for adding new product
|
||||
setFormData({
|
||||
name: '',
|
||||
grocery_id: undefined,
|
||||
category_id: undefined,
|
||||
brand_id: undefined,
|
||||
organic: false,
|
||||
weight: undefined,
|
||||
@@ -85,29 +85,9 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
setError('');
|
||||
}, [editProduct, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
event.preventDefault();
|
||||
if (formData.name.trim() && formData.grocery_id) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, formData, loading, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim() || !formData.grocery_id) {
|
||||
if (!formData.name.trim() || !formData.category_id) {
|
||||
setError('Please fill in all required fields with valid values');
|
||||
return;
|
||||
}
|
||||
@@ -118,7 +98,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
|
||||
const productData = {
|
||||
name: formData.name.trim(),
|
||||
grocery_id: formData.grocery_id!,
|
||||
category_id: formData.category_id!,
|
||||
brand_id: formData.brand_id || undefined,
|
||||
organic: formData.organic,
|
||||
weight: formData.weight || undefined,
|
||||
@@ -136,7 +116,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
grocery_id: undefined,
|
||||
category_id: undefined,
|
||||
brand_id: undefined,
|
||||
organic: false,
|
||||
weight: undefined,
|
||||
@@ -151,7 +131,27 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [formData, editProduct, onProductAdded, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
event.preventDefault();
|
||||
if (formData.name.trim() && formData.category_id) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, formData, loading, onClose, handleSubmit]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value, type } = e.target;
|
||||
@@ -159,7 +159,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
|
||||
: type === 'number' ? (value === '' ? undefined : Number(value))
|
||||
: name === 'brand_id' || name === 'grocery_id' ? (value === '' ? undefined : Number(value))
|
||||
: name === 'brand_id' || name === 'category_id' ? (value === '' ? undefined : Number(value))
|
||||
: value
|
||||
}));
|
||||
};
|
||||
@@ -208,21 +208,21 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="grocery_id" className="block text-sm font-medium text-gray-700">
|
||||
Grocery Type *
|
||||
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">
|
||||
Category *
|
||||
</label>
|
||||
<select
|
||||
id="grocery_id"
|
||||
name="grocery_id"
|
||||
value={formData.grocery_id || ''}
|
||||
id="category_id"
|
||||
name="category_id"
|
||||
value={formData.category_id || ''}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select a grocery type</option>
|
||||
{groceries.map(grocery => (
|
||||
<option key={grocery.id} value={grocery.id}>
|
||||
{grocery.name} ({grocery.category.name})
|
||||
<option value="">Select a category</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -101,6 +101,38 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
}
|
||||
}, [isOpen, loadEventData]);
|
||||
|
||||
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const eventData = {
|
||||
...formData,
|
||||
products: selectedProducts
|
||||
};
|
||||
|
||||
if (isEditMode && editEvent) {
|
||||
await shoppingEventApi.update(editEvent.id, eventData);
|
||||
setMessage('Shopping event updated successfully!');
|
||||
} else {
|
||||
await shoppingEventApi.create(eventData);
|
||||
setMessage('Shopping event created successfully!');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onEventAdded();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving shopping event:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [formData, selectedProducts, isEditMode, editEvent, onEventAdded, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
@@ -126,7 +158,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, formData, selectedProducts, loading, onClose]);
|
||||
}, [isOpen, formData, selectedProducts, loading, onClose, handleSubmit]);
|
||||
|
||||
// Update total amount whenever selectedProducts changes
|
||||
useEffect(() => {
|
||||
@@ -208,38 +240,6 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const eventData = {
|
||||
...formData,
|
||||
products: selectedProducts
|
||||
};
|
||||
|
||||
if (isEditMode && editEvent) {
|
||||
await shoppingEventApi.update(editEvent.id, eventData);
|
||||
setMessage('Shopping event updated successfully!');
|
||||
} else {
|
||||
await shoppingEventApi.create(eventData);
|
||||
setMessage('Shopping event created successfully!');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onEventAdded();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving shopping event:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProductName = (id: number) => {
|
||||
const product = products.find(p => p.id === id);
|
||||
if (!product) return 'Unknown';
|
||||
@@ -349,7 +349,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
<option value={0}>Select a product</option>
|
||||
{Object.entries(
|
||||
getFilteredProducts().reduce((groups, product) => {
|
||||
const category = product.grocery.category.name;
|
||||
const category = product.category.name;
|
||||
if (!groups[category]) {
|
||||
groups[category] = [];
|
||||
}
|
||||
|
||||
@@ -1,277 +0,0 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Grocery } from '../types';
|
||||
import { groceryApi } from '../services/api';
|
||||
import AddGroceryModal from './AddGroceryModal';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
|
||||
const GroceryList: React.FC = () => {
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
||||
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [sortField, setSortField] = useState<string>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroceries();
|
||||
}, []);
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching groceries:', error);
|
||||
setMessage('Error loading groceries. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (grocery: Grocery) => {
|
||||
setDeletingGrocery(grocery);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGrocery) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await groceryApi.delete(deletingGrocery.id);
|
||||
setMessage('Grocery deleted successfully!');
|
||||
setDeletingGrocery(null);
|
||||
fetchGroceries();
|
||||
setTimeout(() => setMessage(''), 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting grocery:', error);
|
||||
if (error.response?.status === 400) {
|
||||
setMessage('Cannot delete grocery: products are still associated with this grocery.');
|
||||
} else {
|
||||
setMessage('Error deleting grocery. Please try again.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setDeletingGrocery(null);
|
||||
};
|
||||
|
||||
const handleEdit = (grocery: Grocery) => {
|
||||
setEditingGrocery(grocery);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingGrocery(null);
|
||||
fetchGroceries();
|
||||
};
|
||||
|
||||
const handleSort = (field: string) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedGroceries = [...groceries].sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
break;
|
||||
case 'category':
|
||||
aValue = a.category.name;
|
||||
bValue = b.category.name;
|
||||
break;
|
||||
case 'created_at':
|
||||
aValue = a.created_at;
|
||||
bValue = b.created_at;
|
||||
break;
|
||||
default:
|
||||
aValue = '';
|
||||
bValue = '';
|
||||
}
|
||||
|
||||
// Handle null/undefined values
|
||||
if (aValue === null || aValue === undefined) aValue = '';
|
||||
if (bValue === null || bValue === undefined) bValue = '';
|
||||
|
||||
// Convert to string for comparison
|
||||
const aStr = String(aValue).toLowerCase();
|
||||
const bStr = String(bValue).toLowerCase();
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
return aStr.localeCompare(bStr);
|
||||
} else {
|
||||
return bStr.localeCompare(aStr);
|
||||
}
|
||||
});
|
||||
|
||||
const getSortIcon = (field: string) => {
|
||||
if (sortField !== field) {
|
||||
return (
|
||||
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
if (sortDirection === 'asc') {
|
||||
return (
|
||||
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||
</svg>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Grocery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`px-4 py-3 rounded ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{groceries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Name
|
||||
{getSortIcon('name')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => handleSort('category')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Category
|
||||
{getSortIcon('category')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => handleSort('created_at')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Created
|
||||
{getSortIcon('created_at')}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{sortedGroceries.map((grocery) => (
|
||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{grocery.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{grocery.category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(grocery.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(grocery)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(grocery)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddGroceryModal
|
||||
grocery={editingGrocery}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={!!deletingGrocery}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Grocery"
|
||||
message={`Are you sure you want to delete "${deletingGrocery?.name}"? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroceryList;
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import Papa from 'papaparse';
|
||||
import { Brand, Grocery, GroceryCategory, Product } from '../types';
|
||||
import { brandApi, groceryApi, groceryCategoryApi, productApi } from '../services/api';
|
||||
import { Brand, GroceryCategory, Product } from '../types';
|
||||
import { brandApi, groceryCategoryApi, productApi } from '../services/api';
|
||||
|
||||
interface ImportExportModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -9,7 +9,7 @@ interface ImportExportModalProps {
|
||||
onDataChanged: () => void;
|
||||
}
|
||||
|
||||
type EntityType = 'brands' | 'groceries' | 'categories' | 'products';
|
||||
type EntityType = 'brands' | 'categories' | 'products';
|
||||
|
||||
interface ImportResult {
|
||||
success: number;
|
||||
@@ -84,29 +84,14 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
filename = 'grocery_categories.csv';
|
||||
break;
|
||||
|
||||
case 'groceries':
|
||||
const groceriesResponse = await groceryApi.getAll();
|
||||
data = groceriesResponse.data.map((grocery: Grocery) => ({
|
||||
id: grocery.id,
|
||||
name: grocery.name,
|
||||
category_id: grocery.category.id,
|
||||
category_name: grocery.category.name,
|
||||
created_at: grocery.created_at,
|
||||
updated_at: grocery.updated_at
|
||||
}));
|
||||
filename = 'groceries.csv';
|
||||
break;
|
||||
|
||||
case 'products':
|
||||
const productsResponse = await productApi.getAll();
|
||||
data = productsResponse.data.map((product: Product) => ({
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
organic: product.organic,
|
||||
grocery_id: product.grocery.id,
|
||||
grocery_name: product.grocery.name,
|
||||
category_id: product.grocery.category.id,
|
||||
category_name: product.grocery.category.name,
|
||||
category_id: product.category.id,
|
||||
category_name: product.category.name,
|
||||
brand_id: product.brand?.id || null,
|
||||
brand_name: product.brand?.name || null,
|
||||
weight: product.weight,
|
||||
@@ -194,16 +179,9 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedEntity === 'groceries') {
|
||||
if (!row.category_name || typeof row.category_name !== 'string' || row.category_name.trim().length === 0) {
|
||||
errors.push(`Row ${rowNum}: Category name is required for groceries`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEntity === 'products') {
|
||||
if (!row.grocery_name || typeof row.grocery_name !== 'string' || row.grocery_name.trim().length === 0) {
|
||||
errors.push(`Row ${rowNum}: Grocery name is required for products`);
|
||||
if (!row.category_name || typeof row.category_name !== 'string' || row.category_name.trim().length === 0) {
|
||||
errors.push(`Row ${rowNum}: Category name is required for products`);
|
||||
return;
|
||||
}
|
||||
if (row.organic !== undefined && typeof row.organic !== 'boolean' && row.organic !== 'true' && row.organic !== 'false') {
|
||||
@@ -214,8 +192,7 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
|
||||
valid.push({
|
||||
name: row.name.trim(),
|
||||
category_name: selectedEntity === 'groceries' ? row.category_name?.trim() : undefined,
|
||||
grocery_name: selectedEntity === 'products' ? row.grocery_name?.trim() : undefined,
|
||||
category_name: selectedEntity === 'products' ? row.category_name?.trim() : undefined,
|
||||
organic: selectedEntity === 'products' ? (row.organic === 'true' || row.organic === true) : undefined,
|
||||
brand_name: selectedEntity === 'products' && row.brand_name ? row.brand_name.trim() : undefined,
|
||||
weight: selectedEntity === 'products' && row.weight ? parseFloat(row.weight) : undefined,
|
||||
@@ -253,19 +230,12 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
let failedCount = 0;
|
||||
const importErrors: string[] = [...errors];
|
||||
|
||||
// Get categories for grocery import
|
||||
// Get categories and brands for product import
|
||||
let categories: GroceryCategory[] = [];
|
||||
if (selectedEntity === 'groceries') {
|
||||
const categoriesResponse = await groceryCategoryApi.getAll();
|
||||
categories = categoriesResponse.data;
|
||||
}
|
||||
|
||||
// Get groceries and brands for product import
|
||||
let groceries: Grocery[] = [];
|
||||
let brands: Brand[] = [];
|
||||
if (selectedEntity === 'products') {
|
||||
const groceriesResponse = await groceryApi.getAll();
|
||||
groceries = groceriesResponse.data;
|
||||
const categoriesResponse = await groceryCategoryApi.getAll();
|
||||
categories = categoriesResponse.data;
|
||||
const brandsResponse = await brandApi.getAll();
|
||||
brands = brandsResponse.data;
|
||||
}
|
||||
@@ -281,25 +251,12 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
case 'categories':
|
||||
await groceryCategoryApi.create({ name: item.name });
|
||||
break;
|
||||
|
||||
case 'groceries':
|
||||
|
||||
case 'products':
|
||||
const category = categories.find(c => c.name.toLowerCase() === item.category_name.toLowerCase());
|
||||
if (!category) {
|
||||
failedCount++;
|
||||
importErrors.push(`Grocery "${item.name}": Category "${item.category_name}" not found`);
|
||||
continue;
|
||||
}
|
||||
await groceryApi.create({
|
||||
name: item.name,
|
||||
category_id: category.id
|
||||
});
|
||||
break;
|
||||
|
||||
case 'products':
|
||||
const grocery = groceries.find(g => g.name.toLowerCase() === item.grocery_name.toLowerCase());
|
||||
if (!grocery) {
|
||||
failedCount++;
|
||||
importErrors.push(`Product "${item.name}": Grocery "${item.grocery_name}" not found`);
|
||||
importErrors.push(`Product "${item.name}": Category "${item.category_name}" not found`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -317,10 +274,10 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
await productApi.create({
|
||||
name: item.name,
|
||||
organic: item.organic || false,
|
||||
grocery_id: grocery.id,
|
||||
category_id: category.id,
|
||||
brand_id: brandId || undefined,
|
||||
weight: item.weight,
|
||||
weight_unit: item.weight_unit || ''
|
||||
weight_unit: item.weight_unit || 'piece'
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -371,10 +328,8 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
case 'brands':
|
||||
case 'categories':
|
||||
return 'name';
|
||||
case 'groceries':
|
||||
return 'name,category_name';
|
||||
case 'products':
|
||||
return 'name,grocery_name (organic,brand_name,weight,weight_unit are optional)';
|
||||
return 'name,category_name (organic,brand_name,weight,weight_unit are optional)';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -446,7 +401,6 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
>
|
||||
<option value="brands">Brands</option>
|
||||
<option value="categories">Grocery Categories</option>
|
||||
<option value="groceries">Groceries</option>
|
||||
<option value="products">Products</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -460,7 +414,7 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
This will download all {selectedEntity} as a CSV file that you can open in Excel or other spreadsheet applications.
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>Exported fields:</strong> ID, name, {selectedEntity === 'groceries' ? 'category_id, category_name, ' : selectedEntity === 'products' ? 'organic, grocery_id, grocery_name, category_id, category_name, brand_id, brand_name, weight, weight_unit, ' : ''}created_at, updated_at
|
||||
<strong>Exported fields:</strong> ID, name, {selectedEntity === 'products' ? 'organic, category_id, category_name, brand_id, brand_name, weight, weight_unit, ' : ''}created_at, updated_at
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -487,14 +441,9 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
||||
<p className="text-sm text-yellow-700 mb-2">
|
||||
<strong>Note:</strong> ID, created_at, and updated_at fields are optional for import and will be ignored if present.
|
||||
</p>
|
||||
{selectedEntity === 'groceries' && (
|
||||
<p className="text-sm text-yellow-700">
|
||||
<strong>Groceries:</strong> Category names must match existing grocery categories exactly.
|
||||
</p>
|
||||
)}
|
||||
{selectedEntity === 'products' && (
|
||||
<p className="text-sm text-yellow-700">
|
||||
<strong>Products:</strong> Grocery names must match existing groceries exactly. Brand names (if provided) must match existing brands exactly.
|
||||
<strong>Products:</strong> Category names must match existing grocery categories exactly. Brand names (if provided) must match existing brands exactly.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -97,13 +97,9 @@ const ProductList: React.FC = () => {
|
||||
aValue = a.name;
|
||||
bValue = b.name;
|
||||
break;
|
||||
case 'grocery':
|
||||
aValue = a.grocery.name;
|
||||
bValue = b.grocery.name;
|
||||
break;
|
||||
case 'category':
|
||||
aValue = a.grocery.category.name;
|
||||
bValue = b.grocery.category.name;
|
||||
aValue = a.category.name;
|
||||
bValue = b.category.name;
|
||||
break;
|
||||
case 'brand':
|
||||
aValue = a.brand?.name || '';
|
||||
@@ -216,15 +212,6 @@ const ProductList: React.FC = () => {
|
||||
{getSortIcon('name')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => handleSort('grocery')}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
Grocery
|
||||
{getSortIcon('grocery')}
|
||||
</div>
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => handleSort('category')}
|
||||
@@ -265,11 +252,8 @@ const ProductList: React.FC = () => {
|
||||
{product.name} {product.organic ? '🌱' : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{product.grocery.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{product.grocery.category.name}
|
||||
{product.category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{product.brand ? product.brand.name : '-'}
|
||||
|
||||
@@ -104,7 +104,6 @@ const ShoppingEventList: React.FC = () => {
|
||||
const handleItemsHover = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
|
||||
if (event.products.length === 0) return;
|
||||
|
||||
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||
const popupWidth = 384; // max-w-md is approximately 384px
|
||||
const popupHeight = 300; // max height we set
|
||||
|
||||
@@ -136,7 +135,6 @@ const ShoppingEventList: React.FC = () => {
|
||||
if (event.products.length === 0) return;
|
||||
|
||||
mouseEvent.stopPropagation();
|
||||
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||
const popupWidth = 384; // max-w-md is approximately 384px
|
||||
const popupHeight = 300; // max height we set
|
||||
|
||||
@@ -442,7 +440,7 @@ const ShoppingEventList: React.FC = () => {
|
||||
{product.name} {product.organic ? '🌱' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{product.grocery?.category?.name || 'Unknown category'}
|
||||
{product.category?.name || 'Unknown category'}
|
||||
</div>
|
||||
{product.brand && (
|
||||
<div className="text-xs text-gray-500">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate, BrandInShop, BrandInShopCreate } from '../types';
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, GroceryCategory, GroceryCategoryCreate, BrandInShop, BrandInShopCreate } from '../types';
|
||||
|
||||
// Use different API URLs based on environment
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
@@ -83,16 +83,6 @@ export const shoppingEventApi = {
|
||||
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
||||
};
|
||||
|
||||
// Grocery API functions
|
||||
export const groceryApi = {
|
||||
getAll: () => api.get<Grocery[]>('/groceries/'),
|
||||
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
|
||||
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
|
||||
update: (id: number, grocery: Partial<GroceryCreate>) =>
|
||||
api.put<Grocery>(`/groceries/${id}`, grocery),
|
||||
delete: (id: number) => api.delete(`/groceries/${id}`),
|
||||
};
|
||||
|
||||
// Statistics API functions
|
||||
export const statsApi = {
|
||||
getCategories: () => api.get('/stats/categories'),
|
||||
|
||||
@@ -20,25 +20,11 @@ export interface GroceryCategoryCreate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Grocery {
|
||||
id: number;
|
||||
name: string;
|
||||
category_id: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
category: GroceryCategory;
|
||||
}
|
||||
|
||||
export interface GroceryCreate {
|
||||
name: string;
|
||||
category_id: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
grocery_id: number;
|
||||
grocery: Grocery;
|
||||
category_id: number;
|
||||
category: GroceryCategory;
|
||||
brand_id?: number;
|
||||
brand?: Brand;
|
||||
organic: boolean;
|
||||
@@ -50,7 +36,7 @@ export interface Product {
|
||||
|
||||
export interface ProductCreate {
|
||||
name: string;
|
||||
grocery_id: number;
|
||||
category_id: number;
|
||||
brand_id?: number;
|
||||
organic: boolean;
|
||||
weight?: number;
|
||||
@@ -81,7 +67,7 @@ export interface ProductInEvent {
|
||||
export interface ProductWithEventData {
|
||||
id: number;
|
||||
name: string;
|
||||
grocery: Grocery;
|
||||
category: GroceryCategory;
|
||||
brand?: Brand;
|
||||
organic: boolean;
|
||||
weight?: number;
|
||||
|
||||
Reference in New Issue
Block a user