- add grocery category
- add Dockerfile
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ProductList from './components/ProductList';
|
||||
import ShopList from './components/ShopList';
|
||||
import BrandList from './components/BrandList';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import ProductList from './components/ProductList';
|
||||
import ShoppingEventList from './components/ShoppingEventList';
|
||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||
import BrandList from './components/BrandList';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import GroceryCategoryList from './components/GroceryCategoryList';
|
||||
|
||||
function Navigation() {
|
||||
const location = useLocation();
|
||||
@@ -16,48 +17,81 @@ function Navigation() {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-blue-600 text-white p-4">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Product Tracker
|
||||
</Link>
|
||||
<div className="space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-3 py-2 rounded ${isActive('/') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/products"
|
||||
className={`px-3 py-2 rounded ${isActive('/products') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
to="/groceries"
|
||||
className={`px-3 py-2 rounded ${isActive('/groceries') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Groceries
|
||||
</Link>
|
||||
<Link
|
||||
to="/shops"
|
||||
className={`px-3 py-2 rounded ${isActive('/shops') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Shops
|
||||
</Link>
|
||||
<Link
|
||||
to="/brands"
|
||||
className={`px-3 py-2 rounded ${isActive('/brands') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Brands
|
||||
</Link>
|
||||
<Link
|
||||
to="/shopping-events"
|
||||
className={`px-3 py-2 rounded ${isActive('/shopping-events') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Shopping Events
|
||||
</Link>
|
||||
<nav className="bg-blue-600 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex space-x-8">
|
||||
<Link
|
||||
to="/"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/shopping-events"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/shopping-events')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Shopping Events
|
||||
</Link>
|
||||
<Link
|
||||
to="/shops"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/shops')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Shops
|
||||
</Link>
|
||||
<Link
|
||||
to="/products"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/products')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
to="/brands"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/brands')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
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 ${
|
||||
isActive('/categories')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Categories
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -67,19 +101,22 @@ function Navigation() {
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/products" element={<ProductList />} />
|
||||
<Route path="/groceries" element={<GroceryList />} />
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/brands" element={<BrandList />} />
|
||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
||||
<Route path="/add-purchase" element={<ShoppingEventForm />} />
|
||||
</Routes>
|
||||
<main className="py-10">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
|
||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
||||
<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>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
111
frontend/src/components/AddGroceryCategoryModal.tsx
Normal file
111
frontend/src/components/AddGroceryCategoryModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GroceryCategory, GroceryCategoryCreate } from '../types';
|
||||
import { groceryCategoryApi } from '../services/api';
|
||||
|
||||
interface AddGroceryCategoryModalProps {
|
||||
category?: GroceryCategory | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ category, onClose }) => {
|
||||
const [formData, setFormData] = useState<GroceryCategoryCreate>({
|
||||
name: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const isEditMode = Boolean(category);
|
||||
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setFormData({
|
||||
name: category.name
|
||||
});
|
||||
}
|
||||
}, [category]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
if (isEditMode && category) {
|
||||
await groceryCategoryApi.update(category.id, formData);
|
||||
setMessage('Category updated successfully!');
|
||||
} else {
|
||||
await groceryCategoryApi.create(formData);
|
||||
setMessage('Category created successfully!');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
|
||||
} 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 Category' : 'Add New Category'}
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Category 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 category name"
|
||||
required
|
||||
/>
|
||||
</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}
|
||||
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 Category' : 'Create Category')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddGroceryCategoryModal;
|
||||
@@ -1,159 +1,121 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { groceryApi } from '../services/api';
|
||||
import { Grocery } from '../types';
|
||||
import { Grocery, GroceryCreate, GroceryCategory } from '../types';
|
||||
import { groceryApi, groceryCategoryApi } from '../services/api';
|
||||
|
||||
interface AddGroceryModalProps {
|
||||
isOpen: boolean;
|
||||
grocery?: Grocery | null;
|
||||
onClose: () => void;
|
||||
onGroceryAdded: () => void;
|
||||
editGrocery?: Grocery | null;
|
||||
}
|
||||
|
||||
interface GroceryFormData {
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => {
|
||||
const [formData, setFormData] = useState<GroceryFormData>({
|
||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) => {
|
||||
const [formData, setFormData] = useState<GroceryCreate>({
|
||||
name: '',
|
||||
category: ''
|
||||
category_id: 0
|
||||
});
|
||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const categories = [
|
||||
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry', 'Frozen',
|
||||
'Bakery', 'Beverages', 'Snacks', 'Health & Beauty', 'Household', 'Other'
|
||||
];
|
||||
const isEditMode = Boolean(grocery);
|
||||
|
||||
const isEditMode = !!editGrocery;
|
||||
|
||||
// Initialize form data when editing
|
||||
useEffect(() => {
|
||||
if (editGrocery) {
|
||||
fetchCategories();
|
||||
if (grocery) {
|
||||
setFormData({
|
||||
name: editGrocery.name,
|
||||
category: editGrocery.category
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
category: ''
|
||||
name: grocery.name,
|
||||
category_id: grocery.category_id
|
||||
});
|
||||
}
|
||||
setError('');
|
||||
}, [editGrocery, isOpen]);
|
||||
}, [grocery]);
|
||||
|
||||
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.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim() || !formData.category.trim()) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const groceryData = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category.trim()
|
||||
};
|
||||
|
||||
if (isEditMode && editGrocery) {
|
||||
await groceryApi.update(editGrocery.id, groceryData);
|
||||
if (isEditMode && grocery) {
|
||||
await groceryApi.update(grocery.id, formData);
|
||||
setMessage('Grocery updated successfully!');
|
||||
} else {
|
||||
await groceryApi.create(groceryData);
|
||||
await groceryApi.create(formData);
|
||||
setMessage('Grocery created successfully!');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
category: ''
|
||||
});
|
||||
|
||||
onGroceryAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(`Failed to ${isEditMode ? 'update' : 'add'} grocery. Please try again.`);
|
||||
console.error(`Error ${isEditMode ? 'updating' : 'adding'} grocery:`, err);
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving grocery:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{isEditMode ? 'Edit Grocery' : 'Add New Grocery'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{isEditMode ? 'Edit Grocery' : 'Add New Grocery'}
|
||||
</h3>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Grocery Name *
|
||||
<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"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
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
|
||||
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"
|
||||
placeholder="e.g., Milk, Bread, Apples"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||
Category *
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
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
|
||||
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 category</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
<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 pt-4">
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -163,10 +125,13 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
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...' : 'Adding...') : (isEditMode ? 'Update Grocery' : 'Add Grocery')}
|
||||
{loading
|
||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||
: (isEditMode ? 'Update Grocery' : 'Create Grocery')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -202,7 +202,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
<option value="">Select a grocery type</option>
|
||||
{groceries.map(grocery => (
|
||||
<option key={grocery.id} value={grocery.id}>
|
||||
{grocery.name} ({grocery.category})
|
||||
{grocery.name} ({grocery.category.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -102,7 +102,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/shopping-events')}
|
||||
onClick={() => navigate('/shopping-events/new')}
|
||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
||||
|
||||
156
frontend/src/components/GroceryCategoryList.tsx
Normal file
156
frontend/src/components/GroceryCategoryList.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GroceryCategory } from '../types';
|
||||
import { groceryCategoryApi } from '../services/api';
|
||||
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
||||
|
||||
const GroceryCategoryList: React.FC = () => {
|
||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<GroceryCategory | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groceryCategoryApi.getAll();
|
||||
setCategories(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
setMessage('Error loading categories. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this category?')) {
|
||||
try {
|
||||
await groceryCategoryApi.delete(id);
|
||||
setMessage('Category deleted successfully!');
|
||||
fetchCategories();
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting category:', error);
|
||||
if (error.response?.status === 400) {
|
||||
setMessage('Cannot delete category: groceries are still associated with this category.');
|
||||
} else {
|
||||
setMessage('Error deleting category. Please try again.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (category: GroceryCategory) => {
|
||||
setEditingCategory(category);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingCategory(null);
|
||||
fetchCategories();
|
||||
};
|
||||
|
||||
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="max-w-4xl mx-auto">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Grocery Categories
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No categories found. Add your first category!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(category.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddGroceryCategoryModal
|
||||
category={editingCategory}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroceryCategoryList;
|
||||
@@ -1,85 +1,60 @@
|
||||
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 [searchParams, setSearchParams] = useSearchParams();
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroceries();
|
||||
|
||||
// Check if we should auto-open the modal
|
||||
if (searchParams.get('add') === 'true') {
|
||||
setIsModalOpen(true);
|
||||
// Remove the parameter from URL
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
}, []);
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch groceries');
|
||||
console.error('Error fetching groceries:', err);
|
||||
} catch (error) {
|
||||
console.error('Error fetching groceries:', error);
|
||||
setMessage('Error loading groceries. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroceryAdded = () => {
|
||||
fetchGroceries(); // Refresh the groceries list
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this grocery?')) {
|
||||
try {
|
||||
await groceryApi.delete(id);
|
||||
setMessage('Grocery deleted successfully!');
|
||||
fetchGroceries();
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} 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(''), 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditGrocery = (grocery: Grocery) => {
|
||||
const handleEdit = (grocery: Grocery) => {
|
||||
setEditingGrocery(grocery);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteGrocery = (grocery: Grocery) => {
|
||||
setDeletingGrocery(grocery);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGrocery) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await groceryApi.delete(deletingGrocery.id);
|
||||
setDeletingGrocery(null);
|
||||
fetchGroceries(); // Refresh the groceries list
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting grocery:', err);
|
||||
// Handle specific error message from backend
|
||||
if (err.response?.status === 400) {
|
||||
setError('Cannot delete grocery: products are still associated with this grocery');
|
||||
} else {
|
||||
setError('Failed to delete grocery. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingGrocery(null);
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setDeletingGrocery(null);
|
||||
fetchGroceries();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@@ -91,98 +66,95 @@ const GroceryList: React.FC = () => {
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Groceries
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add Grocery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groceries.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{groceries.map((grocery) => (
|
||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{grocery.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{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-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(grocery)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(grocery.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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 item.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
{groceries.map((grocery) => (
|
||||
<div key={grocery.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{grocery.name}</h3>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditGrocery(grocery)}
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteGrocery(grocery)}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{grocery.category}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Added {new Date(grocery.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{grocery.updated_at && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Updated {new Date(grocery.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddGroceryModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onGroceryAdded={handleGroceryAdded}
|
||||
editGrocery={editingGrocery}
|
||||
/>
|
||||
|
||||
<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}
|
||||
/>
|
||||
{isModalOpen && (
|
||||
<AddGroceryModal
|
||||
grocery={editingGrocery}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -149,7 +149,7 @@ const ProductList: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{product.grocery.name}</div>
|
||||
<div className="text-xs text-gray-500">{product.grocery.category}</div>
|
||||
<div className="text-xs text-gray-500">{product.grocery.category.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{product.brand ? product.brand.name : '-'}
|
||||
|
||||
@@ -283,7 +283,7 @@ const ShoppingEventForm: React.FC = () => {
|
||||
<option value={0}>Select a product</option>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category.name}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@@ -66,7 +66,7 @@ const ShoppingEventList: React.FC = () => {
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
||||
<button
|
||||
onClick={() => navigate('/add-purchase')}
|
||||
onClick={() => navigate('/shopping-events/new')}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Event
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate } from '../types';
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate } from '../types';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
@@ -51,6 +51,15 @@ export const brandApi = {
|
||||
delete: (id: number) => api.delete(`/brands/${id}`),
|
||||
};
|
||||
|
||||
// Grocery Category API functions
|
||||
export const groceryCategoryApi = {
|
||||
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
|
||||
getById: (id: number) => api.get<GroceryCategory>(`/grocery-categories/${id}`),
|
||||
create: (category: GroceryCategoryCreate) => api.post<GroceryCategory>('/grocery-categories/', category),
|
||||
update: (id: number, category: Partial<GroceryCategoryCreate>) => api.put<GroceryCategory>(`/grocery-categories/${id}`, category),
|
||||
delete: (id: number) => api.delete(`/grocery-categories/${id}`),
|
||||
};
|
||||
|
||||
// Shopping Event API functions
|
||||
export const shoppingEventApi = {
|
||||
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
|
||||
|
||||
@@ -9,17 +9,29 @@ export interface BrandCreate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Grocery {
|
||||
export interface GroceryCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
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: string;
|
||||
category_id: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
|
||||
Reference in New Issue
Block a user