add Brand Management
This commit is contained in:
parent
d27871160e
commit
25c09dfecc
106
backend/main.py
106
backend/main.py
@ -26,32 +26,45 @@ app.add_middleware(
|
||||
|
||||
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
||||
"""Build a shopping event response with products from the association table"""
|
||||
# Get products with their event-specific data
|
||||
# Get products with their event-specific data including brand information
|
||||
product_data = db.execute(
|
||||
text("""
|
||||
SELECT p.id, p.name, p.category, p.organic, p.weight, p.weight_unit,
|
||||
sep.amount, sep.price
|
||||
sep.amount, sep.price, b.id as brand_id, b.name as brand_name,
|
||||
b.created_at as brand_created_at, b.updated_at as brand_updated_at
|
||||
FROM products p
|
||||
JOIN shopping_event_products sep ON p.id = sep.product_id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
WHERE sep.shopping_event_id = :event_id
|
||||
"""),
|
||||
{"event_id": event.id}
|
||||
).fetchall()
|
||||
|
||||
# Convert to ProductWithEventData objects
|
||||
products_with_data = [
|
||||
schemas.ProductWithEventData(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
category=row.category,
|
||||
organic=row.organic,
|
||||
weight=row.weight,
|
||||
weight_unit=row.weight_unit,
|
||||
amount=row.amount,
|
||||
price=row.price
|
||||
products_with_data = []
|
||||
for row in product_data:
|
||||
brand = None
|
||||
if row.brand_id is not None:
|
||||
brand = schemas.Brand(
|
||||
id=row.brand_id,
|
||||
name=row.brand_name,
|
||||
created_at=row.brand_created_at,
|
||||
updated_at=row.brand_updated_at
|
||||
)
|
||||
|
||||
products_with_data.append(
|
||||
schemas.ProductWithEventData(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
category=row.category,
|
||||
brand=brand,
|
||||
organic=row.organic,
|
||||
weight=row.weight,
|
||||
weight_unit=row.weight_unit,
|
||||
amount=row.amount,
|
||||
price=row.price
|
||||
)
|
||||
)
|
||||
for row in product_data
|
||||
]
|
||||
|
||||
return schemas.ShoppingEventResponse(
|
||||
id=event.id,
|
||||
@ -72,6 +85,12 @@ def read_root():
|
||||
# Product endpoints
|
||||
@app.post("/products/", response_model=schemas.Product)
|
||||
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
||||
# Validate brand exists if brand_id is provided
|
||||
if product.brand_id is not None:
|
||||
brand = db.query(models.Brand).filter(models.Brand.id == product.brand_id).first()
|
||||
if brand is None:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
|
||||
db_product = models.Product(**product.dict())
|
||||
db.add(db_product)
|
||||
db.commit()
|
||||
@ -96,7 +115,13 @@ def update_product(product_id: int, product_update: schemas.ProductUpdate, db: S
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
# Validate brand exists if brand_id is being updated
|
||||
update_data = product_update.dict(exclude_unset=True)
|
||||
if 'brand_id' in update_data and update_data['brand_id'] is not None:
|
||||
brand = db.query(models.Brand).filter(models.Brand.id == update_data['brand_id']).first()
|
||||
if brand is None:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
|
||||
for field, value in update_data.items():
|
||||
setattr(product, field, value)
|
||||
|
||||
@ -159,6 +184,59 @@ def delete_shop(shop_id: int, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
return {"message": "Shop deleted successfully"}
|
||||
|
||||
# Brand endpoints
|
||||
@app.post("/brands/", response_model=schemas.Brand)
|
||||
def create_brand(brand: schemas.BrandCreate, db: Session = Depends(get_db)):
|
||||
db_brand = models.Brand(**brand.dict())
|
||||
db.add(db_brand)
|
||||
db.commit()
|
||||
db.refresh(db_brand)
|
||||
return db_brand
|
||||
|
||||
@app.get("/brands/", response_model=List[schemas.Brand])
|
||||
def read_brands(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
brands = db.query(models.Brand).offset(skip).limit(limit).all()
|
||||
return brands
|
||||
|
||||
@app.get("/brands/{brand_id}", response_model=schemas.Brand)
|
||||
def read_brand(brand_id: int, db: Session = Depends(get_db)):
|
||||
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
|
||||
if brand is None:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
return brand
|
||||
|
||||
@app.put("/brands/{brand_id}", response_model=schemas.Brand)
|
||||
def update_brand(brand_id: int, brand_update: schemas.BrandUpdate, db: Session = Depends(get_db)):
|
||||
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
|
||||
if brand is None:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
|
||||
update_data = brand_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(brand, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(brand)
|
||||
return brand
|
||||
|
||||
@app.delete("/brands/{brand_id}")
|
||||
def delete_brand(brand_id: int, db: Session = Depends(get_db)):
|
||||
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
|
||||
if brand is None:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
|
||||
# Check if any products reference this brand
|
||||
products_with_brand = db.query(models.Product).filter(models.Product.brand_id == brand_id).first()
|
||||
if products_with_brand:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete brand: products are still associated with this brand"
|
||||
)
|
||||
|
||||
db.delete(brand)
|
||||
db.commit()
|
||||
return {"message": "Brand deleted successfully"}
|
||||
|
||||
# Shopping Event endpoints
|
||||
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
||||
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
||||
|
||||
@ -17,12 +17,24 @@ shopping_event_products = Table(
|
||||
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
|
||||
)
|
||||
|
||||
class Brand(Base):
|
||||
__tablename__ = "brands"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
products = relationship("Product", back_populates="brand")
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
category = Column(String, nullable=False)
|
||||
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True)
|
||||
organic = Column(Boolean, default=False)
|
||||
weight = Column(Float, nullable=True) # in grams or kg
|
||||
weight_unit = Column(String, default="piece") # "g", "kg", "ml", "l", "piece"
|
||||
@ -30,6 +42,7 @@ class Product(Base):
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
brand = relationship("Brand", back_populates="products")
|
||||
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
|
||||
|
||||
class Shop(Base):
|
||||
|
||||
@ -2,10 +2,29 @@ from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# Brand schemas
|
||||
class BrandBase(BaseModel):
|
||||
name: str
|
||||
|
||||
class BrandCreate(BrandBase):
|
||||
pass
|
||||
|
||||
class BrandUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
class Brand(BrandBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Base schemas
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
brand_id: Optional[int] = None
|
||||
organic: bool = False
|
||||
weight: Optional[float] = None
|
||||
weight_unit: str = "g"
|
||||
@ -16,6 +35,7 @@ class ProductCreate(ProductBase):
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
brand_id: Optional[int] = None
|
||||
organic: Optional[bool] = None
|
||||
weight: Optional[float] = None
|
||||
weight_unit: Optional[str] = None
|
||||
@ -24,6 +44,7 @@ class Product(ProductBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
brand: Optional[Brand] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@ -60,6 +81,7 @@ class ProductWithEventData(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: str
|
||||
brand: Optional[Brand] = None
|
||||
organic: bool
|
||||
weight: Optional[float] = None
|
||||
weight_unit: str
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||
<mxGraphModel dx="999" dy="529" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<mxGraphModel dx="577" dy="426" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
@ -35,7 +35,7 @@
|
||||
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="390" y="440" width="180" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
|
||||
@ -438,7 +438,7 @@
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="172" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="170" vertex="1">
|
||||
<mxCell id="172" value="grocery_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="170" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
|
||||
@ -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 />} />
|
||||
|
||||
149
frontend/src/components/AddBrandModal.tsx
Normal file
149
frontend/src/components/AddBrandModal.tsx
Normal 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;
|
||||
@ -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">
|
||||
|
||||
184
frontend/src/components/BrandList.tsx
Normal file
184
frontend/src/components/BrandList.tsx
Normal 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;
|
||||
@ -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>
|
||||
|
||||
@ -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/'),
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user