remove intermediate grocery table and add related_products feature

This commit is contained in:
2025-05-28 09:22:47 +02:00
parent 3ea5db4214
commit 112ea41e88
16 changed files with 1140 additions and 1532 deletions

View File

@@ -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;

View File

@@ -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>

View File

@@ -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] = [];
}

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 : '-'}

View File

@@ -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">