add Brand Management

This commit is contained in:
2025-05-26 20:44:15 +02:00
parent d27871160e
commit 25c09dfecc
11 changed files with 548 additions and 21 deletions

View File

@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react
import Dashboard from './components/Dashboard';
import ProductList from './components/ProductList';
import ShopList from './components/ShopList';
import BrandList from './components/BrandList';
import ShoppingEventList from './components/ShoppingEventList';
import ShoppingEventForm from './components/ShoppingEventForm';
@@ -38,6 +39,12 @@ function Navigation() {
>
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'}`}
@@ -60,6 +67,7 @@ function App() {
<Route path="/" element={<Dashboard />} />
<Route path="/products" element={<ProductList />} />
<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 />} />

View File

@@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
import { brandApi } from '../services/api';
import { Brand } from '../types';
interface AddBrandModalProps {
isOpen: boolean;
onClose: () => void;
onBrandAdded: () => void;
editBrand?: Brand | null;
}
interface BrandFormData {
name: string;
}
const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandAdded, editBrand }) => {
const [formData, setFormData] = useState<BrandFormData>({
name: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!editBrand;
// Initialize form data when editing
useEffect(() => {
if (editBrand) {
setFormData({
name: editBrand.name
});
} else {
setFormData({
name: ''
});
}
setError('');
}, [editBrand, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
setError('Please enter a brand name');
return;
}
try {
setLoading(true);
setError('');
const brandData = {
name: formData.name.trim()
};
if (isEditMode && editBrand) {
await brandApi.update(editBrand.id, brandData);
} else {
await brandApi.create(brandData);
}
// Reset form
setFormData({
name: ''
});
onBrandAdded();
onClose();
} catch (err) {
setError(`Failed to ${isEditMode ? 'update' : 'add'} brand. Please try again.`);
console.error(`Error ${isEditMode ? 'updating' : 'adding'} brand:`, err);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 Brand' : 'Add New Brand'}
</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>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Brand Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
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"
placeholder="e.g., Coca-Cola, Nestlé, Apple"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<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...' : 'Adding...') : (isEditMode ? 'Update Brand' : 'Add Brand')}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddBrandModal;

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { productApi } from '../services/api';
import { Product } from '../types';
import { productApi, brandApi } from '../services/api';
import { Product, Brand } from '../types';
interface AddProductModalProps {
isOpen: boolean;
@@ -12,6 +12,7 @@ interface AddProductModalProps {
interface ProductFormData {
name: string;
category: string;
brand_id?: number;
organic: boolean;
weight?: number;
weight_unit: string;
@@ -21,10 +22,12 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
const [formData, setFormData] = useState<ProductFormData>({
name: '',
category: '',
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
});
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
@@ -35,12 +38,29 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
// Fetch brands when modal opens
useEffect(() => {
if (isOpen) {
fetchBrands();
}
}, [isOpen]);
const fetchBrands = async () => {
try {
const response = await brandApi.getAll();
setBrands(response.data);
} catch (err) {
console.error('Error fetching brands:', err);
}
};
// Populate form when editing
useEffect(() => {
if (editProduct) {
setFormData({
name: editProduct.name,
category: editProduct.category,
brand_id: editProduct.brand_id,
organic: editProduct.organic,
weight: editProduct.weight,
weight_unit: editProduct.weight_unit
@@ -50,6 +70,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
setFormData({
name: '',
category: '',
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
@@ -71,7 +92,8 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
const productData = {
...formData,
weight: formData.weight || undefined
weight: formData.weight || undefined,
brand_id: formData.brand_id || undefined
};
if (editProduct) {
@@ -86,6 +108,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
setFormData({
name: '',
category: '',
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
@@ -107,6 +130,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' ? (value === '' ? undefined : Number(value))
: value
}));
};
@@ -173,6 +197,24 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
</select>
</div>
<div>
<label htmlFor="brand_id" className="block text-sm font-medium text-gray-700">
Brand (Optional)
</label>
<select
id="brand_id"
name="brand_id"
value={formData.brand_id || ''}
onChange={handleChange}
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 brand (optional)</option>
{brands.map(brand => (
<option key={brand.id} value={brand.id}>{brand.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="weight" className="block text-sm font-medium text-gray-700">

View File

@@ -0,0 +1,184 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Brand } from '../types';
import { brandApi } from '../services/api';
import AddBrandModal from './AddBrandModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const BrandList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBrand, setEditingBrand] = useState<Brand | null>(null);
const [deletingBrand, setDeletingBrand] = useState<Brand | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
useEffect(() => {
fetchBrands();
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const fetchBrands = async () => {
try {
setLoading(true);
const response = await brandApi.getAll();
setBrands(response.data);
} catch (err) {
setError('Failed to fetch brands');
console.error('Error fetching brands:', err);
} finally {
setLoading(false);
}
};
const handleBrandAdded = () => {
fetchBrands(); // Refresh the brands list
};
const handleEditBrand = (brand: Brand) => {
setEditingBrand(brand);
setIsModalOpen(true);
};
const handleDeleteBrand = (brand: Brand) => {
setDeletingBrand(brand);
};
const confirmDelete = async () => {
if (!deletingBrand) return;
try {
setDeleteLoading(true);
await brandApi.delete(deletingBrand.id);
setDeletingBrand(null);
fetchBrands(); // Refresh the brands list
} catch (err: any) {
console.error('Error deleting brand:', err);
// Handle specific error message from backend
if (err.response?.status === 400) {
setError('Cannot delete brand: products are still associated with this brand');
} else {
setError('Failed to delete brand. Please try again.');
}
} finally {
setDeleteLoading(false);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingBrand(null);
};
const handleCloseDeleteModal = () => {
setDeletingBrand(null);
};
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">Brands</h1>
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add New Brand
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{brands.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="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a1.994 1.994 0 01-1.414.586H7m0-18v18m0-18h.01" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No brands</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
{brands.map((brand) => (
<div key={brand.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">{brand.name}</h3>
<div className="flex space-x-2">
<button
onClick={() => handleEditBrand(brand)}
className="text-indigo-600 hover:text-indigo-900 text-sm"
>
Edit
</button>
<button
onClick={() => handleDeleteBrand(brand)}
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 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(brand.created_at).toLocaleDateString()}
</div>
{brand.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(brand.updated_at).toLocaleDateString()}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<AddBrandModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onBrandAdded={handleBrandAdded}
editBrand={editingBrand}
/>
<ConfirmDeleteModal
isOpen={!!deletingBrand}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Brand"
message={`Are you sure you want to delete "${deletingBrand?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);
};
export default BrandList;

View File

@@ -125,6 +125,9 @@ const ProductList: React.FC = () => {
<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">
Brand
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Weight
</th>
@@ -149,6 +152,9 @@ const ProductList: React.FC = () => {
{product.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.brand ? product.brand.name : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
</td>

View File

@@ -1,4 +1,4 @@
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate } from '../types';
const API_BASE_URL = 'http://localhost:8000';
@@ -41,6 +41,16 @@ export const shopApi = {
delete: (id: number) => api.delete(`/shops/${id}`),
};
// Brand API functions
export const brandApi = {
getAll: () => api.get<Brand[]>('/brands/'),
getById: (id: number) => api.get<Brand>(`/brands/${id}`),
create: (brand: BrandCreate) => api.post<Brand>('/brands/', brand),
update: (id: number, brand: Partial<BrandCreate>) =>
api.put<Brand>(`/brands/${id}`, brand),
delete: (id: number) => api.delete(`/brands/${id}`),
};
// Shopping Event API functions
export const shoppingEventApi = {
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),

View File

@@ -1,7 +1,20 @@
export interface Brand {
id: number;
name: string;
created_at: string;
updated_at?: string;
}
export interface BrandCreate {
name: string;
}
export interface Product {
id: number;
name: string;
category: string;
brand_id?: number;
brand?: Brand;
organic: boolean;
weight?: number;
weight_unit: string;
@@ -12,6 +25,7 @@ export interface Product {
export interface ProductCreate {
name: string;
category: string;
brand_id?: number;
organic: boolean;
weight?: number;
weight_unit: string;
@@ -42,6 +56,7 @@ export interface ProductWithEventData {
id: number;
name: string;
category: string;
brand?: Brand;
organic: boolean;
weight?: number;
weight_unit: string;