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

3
.gitignore vendored
View File

@@ -215,6 +215,9 @@ $RECYCLE.BIN/
# Linux # Linux
*~ *~
# Temporary files starting with .$
.$*
# Logs # Logs
logs logs
*.log *.log

141
README.md
View File

@@ -19,8 +19,10 @@ A web application for tracking product prices and shopping events. Built with Fa
- **Product Management**: Add, edit, and track product items with prices, categories, and organic status - **Product Management**: Add, edit, and track product items with prices, categories, and organic status
- **Shop Management**: Manage different shops with locations - **Shop Management**: Manage different shops with locations
- **Brand Management**: Track product brands and their availability in different shops
- **Shopping Events**: Record purchases with multiple products and amounts - **Shopping Events**: Record purchases with multiple products and amounts
- **Price Tracking**: Monitor price changes over time - **Price Tracking**: Monitor price changes over time
- **Import/Export**: Bulk import and export data via CSV files
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS - **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
## Quick Start with Docker ## Quick Start with Docker
@@ -73,7 +75,7 @@ For detailed Docker deployment instructions, see [DOCKER_DEPLOYMENT.md](DOCKER_D
- React 18 with TypeScript - React 18 with TypeScript
- React Router - Client-side routing - React Router - Client-side routing
- Tailwind CSS - Utility-first CSS framework - Tailwind CSS - Utility-first CSS framework
- Axios - HTTP client for API calls - PapaParse - CSV parsing for import/export
**Deployment:** **Deployment:**
- Docker & Docker Compose - Docker & Docker Compose
@@ -106,22 +108,15 @@ For detailed Docker deployment instructions, see [DOCKER_DEPLOYMENT.md](DOCKER_D
- `created_at`: DateTime, Creation timestamp (auto-generated) - `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated) - `updated_at`: DateTime, Last update timestamp (auto-updated)
#### Groceries (`groceries` table)
- `id`: Integer, Primary key, Auto-increment
- `name`: String, Grocery item name (indexed, required)
- `category_id`: Integer, Foreign key to grocery_categories (required)
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
#### Products (`products` table) #### Products (`products` table)
- `id`: Integer, Primary key, Auto-increment - `id`: Integer, Primary key, Auto-increment
- `name`: String, Product name (indexed, required) - `name`: String, Product name (indexed, required)
- `grocery_id`: Integer, Foreign key to groceries (required) - `category_id`: Integer, Foreign key to grocery_categories (required)
- `brand_id`: Integer, Foreign key to brands (optional) - `brand_id`: Integer, Foreign key to brands (optional)
- `organic`: Boolean, Organic flag (default: false) - `organic`: Boolean, Organic flag (default: false)
- `weight`: Float, Weight/volume (optional) - `weight`: Float, Weight/volume (optional)
- `weight_unit`: String, Unit of measurement (default: "piece") - `weight_unit`: String, Unit of measurement (default: "piece")
- Supported units: "g", "kg", "ml", "l", "piece" - Supported units: "g", "kg", "lb", "oz", "ml", "l", "piece"
- `created_at`: DateTime, Creation timestamp (auto-generated) - `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated) - `updated_at`: DateTime, Last update timestamp (auto-updated)
@@ -142,6 +137,14 @@ For detailed Docker deployment instructions, see [DOCKER_DEPLOYMENT.md](DOCKER_D
- `created_at`: DateTime, Creation timestamp (auto-generated) - `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated) - `updated_at`: DateTime, Last update timestamp (auto-updated)
#### Brands in Shops (`brands_in_shops` table)
Association table tracking which brands are available in which shops:
- `id`: Integer, Primary key, Auto-increment
- `shop_id`: Integer, Foreign key to shops (required)
- `brand_id`: Integer, Foreign key to brands (required)
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
### Association Table ### Association Table
#### Shopping Event Products (`shopping_event_products` table) #### Shopping Event Products (`shopping_event_products` table)
@@ -155,58 +158,73 @@ Many-to-many relationship between shopping events and products with additional d
### Relationships ### Relationships
``` ```
┌─────────────────┐ 1:N ┌─────────────────┐ 1:N ┌─────────────────┐ 1:N ┌─────────────────┐ ┌─────────────────┐ 1:N ┌─────────────────┐ 1:N ┌─────────────────┐
│ Brands │ ────────→ │ Products │ ←──────── │ Groceries │ ←──────── │ Grocery │ │ Brands │ ────────→ │ Products │ ←──────── │ Grocery │
│ │ │ │ │ │ │ Categories │ │ │ │ │ │ Categories │
│ • id │ │ • id │ │ • id │ │ • id │ │ • id │ │ • id │ │ • id │
│ • name │ │ • name │ │ • name │ │ • name │ │ • name │ │ • name │ │ • name │
│ • created_at │ │ • grocery_id │ │ • category_id │ │ • created_at │ │ • created_at │ │ • category_id │ │ • created_at │
│ • updated_at │ │ • brand_id │ │ • created_at │ │ • updated_at │ │ • updated_at │ │ • brand_id │ │ • updated_at │
└─────────────────┘ │ • organic │ │ • updated_at │ │ • updated_at │ └─────────────────┘ │ • organic │ └─────────────────┘
│ • weight │ └─────────────────┘ └─────────────────┘ │ • weight │
│ • weight_unit │ │ • weight_unit │
│ • created_at │ │ • created_at │
│ • updated_at │ │ • updated_at │
└─────────────────┘ └─────────────────┘
│ N:M │ N:M
┌─────────────────────────────┐ ┌─────────────────────────────┐
│ Shopping Event Products │ │ Shopping Event Products │
│ │ (Association Table) │
│ │ │
│ │ • id │
│ │ • shopping_event_id │
│ │ • product_id │
│ │ • amount │
│ │ • price │
│ └─────────────────────────────┘
│ │
│ │ N:1
│ ▼
│ ┌─────────────────┐ 1:N ┌─────────────────┐
│ │ Shops │ ────────→ │ Shopping Events │
│ │ │ │ │
│ │ • id │ │ • id │
│ │ • name │ │ • shop_id │
│ │ • city │ │ • date │
│ │ • address │ │ • total_amount │
│ │ • created_at │ │ • notes │
│ │ • updated_at │ │ • created_at │
│ └─────────────────┘ │ • updated_at │
│ │ └─────────────────┘
│ │
│ │ N:M
│ ▼
│ ┌─────────────────────────────┐
└────────────→ │ Brands in Shops │
│ (Association Table) │ │ (Association Table) │
│ │ │ │
│ • id │ │ • id │
│ • shopping_event_id │ • shop_id
│ • product_id │ │ • brand_id
│ • amount │ • created_at
│ • price │ • updated_at
└─────────────────────────────┘ └─────────────────────────────┘
│ N:1
┌─────────────────┐ 1:N ┌─────────────────┐
│ Shops │ ────────→ │ Shopping Events │
│ │ │ │
│ • id │ │ • id │
│ • name │ │ • shop_id │
│ • city │ │ • date │
│ • address │ │ • total_amount │
│ • created_at │ │ • notes │
│ • updated_at │ │ • created_at │
└─────────────────┘ │ • updated_at │
└─────────────────┘
``` ```
### Key Features ### Key Features
- **Hierarchical Product Organization**: Products are organized by grocery type and category (e.g., "Apples" → "Fruits" → "Fresh Produce") - **Direct Product-Category Relationship**: Products are directly linked to categories for simplified organization
- **Brand Tracking**: Optional brand association for products - **Brand Tracking**: Optional brand association for products with shop availability tracking
- **Price History**: Each product purchase stores the price at that time, enabling price tracking - **Price History**: Each product purchase stores the price at that time, enabling price tracking
- **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples) - **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples)
- **Auto-calculation**: Total amount can be automatically calculated from individual items - **Auto-calculation**: Total amount can be automatically calculated from individual items
- **Free Items**: Supports items with price 0 (samples, promotions, etc.) - **Free Items**: Supports items with price 0 (samples, promotions, etc.)
- **Shop-Brand Filtering**: Products can be filtered by brands available in specific shops
- **Audit Trail**: All entities have creation timestamps for tracking - **Audit Trail**: All entities have creation timestamps for tracking
- **Data Integrity**: Foreign key constraints ensure referential integrity - **Data Integrity**: Foreign key constraints ensure referential integrity
- **Import/Export**: CSV-based bulk data operations for all entity types
## Development Setup ## Development Setup
@@ -294,13 +312,6 @@ Many-to-many relationship between shopping events and products with additional d
- `PUT /grocery-categories/{id}` - Update grocery category - `PUT /grocery-categories/{id}` - Update grocery category
- `DELETE /grocery-categories/{id}` - Delete grocery category - `DELETE /grocery-categories/{id}` - Delete grocery category
### Groceries
- `GET /groceries/` - List all groceries
- `POST /groceries/` - Create new grocery
- `GET /groceries/{id}` - Get specific grocery
- `PUT /groceries/{id}` - Update grocery
- `DELETE /groceries/{id}` - Delete grocery
### Products ### Products
- `GET /products/` - List all products - `GET /products/` - List all products
- `POST /products/` - Create new product - `POST /products/` - Create new product
@@ -315,6 +326,14 @@ Many-to-many relationship between shopping events and products with additional d
- `PUT /shops/{id}` - Update shop - `PUT /shops/{id}` - Update shop
- `DELETE /shops/{id}` - Delete shop - `DELETE /shops/{id}` - Delete shop
### Brands in Shops
- `GET /brands-in-shops/` - List all brand-shop associations
- `POST /brands-in-shops/` - Create new brand-shop association
- `GET /brands-in-shops/{id}` - Get specific brand-shop association
- `GET /brands-in-shops/shop/{shop_id}` - Get brands available in specific shop
- `GET /brands-in-shops/brand/{brand_id}` - Get shops that carry specific brand
- `DELETE /brands-in-shops/{id}` - Delete brand-shop association
### Shopping Events ### Shopping Events
- `GET /shopping-events/` - List all shopping events - `GET /shopping-events/` - List all shopping events
- `POST /shopping-events/` - Create new shopping event - `POST /shopping-events/` - Create new shopping event
@@ -329,10 +348,14 @@ Many-to-many relationship between shopping events and products with additional d
## Usage ## Usage
1. **Add Shops**: Start by adding shops where you buy products 1. **Add Shops**: Start by adding shops where you buy products
2. **Add Products**: Create product items with prices and categories 2. **Add Categories**: Create grocery categories (e.g., "Dairy", "Produce", "Meat")
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events 3. **Add Brands**: Create brands for your products (optional)
4. **Track Prices**: Monitor how prices change over time 4. **Configure Shop-Brand Availability**: Associate brands with shops where they're available
5. **View Statistics**: Analyze spending patterns by category and shop 5. **Add Products**: Create product items linked directly to categories and optionally to brands
6. **Record Purchases**: Use the "Add Shopping Event" form to record purchases with multiple products
7. **Track Prices**: Monitor how prices change over time for the same products
8. **Import/Export Data**: Use CSV files to bulk import or export your data
9. **View Statistics**: Analyze spending patterns by category and shop
## Deployment ## Deployment

View File

@@ -26,21 +26,18 @@ app.add_middleware(
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse: def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
"""Build a shopping event response with products from the association table""" """Build a shopping event response with products from the association table"""
# Get products with their event-specific data including grocery and brand information # Get products with their event-specific data including category and brand information
product_data = db.execute( product_data = db.execute(
text(""" text("""
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit, SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
sep.amount, sep.price, sep.amount, sep.price,
g.id as grocery_id, g.name as grocery_name,
g.created_at as grocery_created_at, g.updated_at as grocery_updated_at,
gc.id as category_id, gc.name as category_name, gc.id as category_id, gc.name as category_name,
gc.created_at as category_created_at, gc.updated_at as category_updated_at, gc.created_at as category_created_at, gc.updated_at as category_updated_at,
b.id as brand_id, b.name as brand_name, b.id as brand_id, b.name as brand_name,
b.created_at as brand_created_at, b.updated_at as brand_updated_at b.created_at as brand_created_at, b.updated_at as brand_updated_at
FROM products p FROM products p
JOIN shopping_event_products sep ON p.id = sep.product_id JOIN shopping_event_products sep ON p.id = sep.product_id
JOIN groceries g ON p.grocery_id = g.id JOIN grocery_categories gc ON p.category_id = gc.id
JOIN grocery_categories gc ON g.category_id = gc.id
LEFT JOIN brands b ON p.brand_id = b.id LEFT JOIN brands b ON p.brand_id = b.id
WHERE sep.shopping_event_id = :event_id WHERE sep.shopping_event_id = :event_id
"""), """),
@@ -57,15 +54,6 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
updated_at=row.category_updated_at updated_at=row.category_updated_at
) )
grocery = schemas.Grocery(
id=row.grocery_id,
name=row.grocery_name,
category_id=row.category_id,
created_at=row.grocery_created_at,
updated_at=row.grocery_updated_at,
category=category
)
brand = None brand = None
if row.brand_id is not None: if row.brand_id is not None:
brand = schemas.Brand( brand = schemas.Brand(
@@ -79,7 +67,7 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
schemas.ProductWithEventData( schemas.ProductWithEventData(
id=row.id, id=row.id,
name=row.name, name=row.name,
grocery=grocery, category=category,
brand=brand, brand=brand,
organic=row.organic, organic=row.organic,
weight=row.weight, weight=row.weight,
@@ -108,10 +96,10 @@ def read_root():
# Product endpoints # Product endpoints
@app.post("/products/", response_model=schemas.Product) @app.post("/products/", response_model=schemas.Product)
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)): def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
# Validate grocery exists # Validate category exists
grocery = db.query(models.Grocery).filter(models.Grocery.id == product.grocery_id).first() category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == product.category_id).first()
if grocery is None: if category is None:
raise HTTPException(status_code=404, detail="Grocery not found") raise HTTPException(status_code=404, detail="Category not found")
# Validate brand exists if brand_id is provided # Validate brand exists if brand_id is provided
if product.brand_id is not None: if product.brand_id is not None:
@@ -145,11 +133,11 @@ def update_product(product_id: int, product_update: schemas.ProductUpdate, db: S
update_data = product_update.dict(exclude_unset=True) update_data = product_update.dict(exclude_unset=True)
# Validate grocery exists if grocery_id is being updated # Validate category exists if category_id is being updated
if 'grocery_id' in update_data: if 'category_id' in update_data:
grocery = db.query(models.Grocery).filter(models.Grocery.id == update_data['grocery_id']).first() category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == update_data['category_id']).first()
if grocery is None: if category is None:
raise HTTPException(status_code=404, detail="Grocery not found") raise HTTPException(status_code=404, detail="Category not found")
# Validate brand exists if brand_id is being updated # Validate brand exists if brand_id is being updated
if 'brand_id' in update_data and update_data['brand_id'] is not None: if 'brand_id' in update_data and update_data['brand_id'] is not None:
@@ -382,83 +370,18 @@ def delete_grocery_category(category_id: int, db: Session = Depends(get_db)):
if category is None: if category is None:
raise HTTPException(status_code=404, detail="Grocery category not found") raise HTTPException(status_code=404, detail="Grocery category not found")
# Check if any groceries reference this category # Check if any products reference this category
groceries_with_category = db.query(models.Grocery).filter(models.Grocery.category_id == category_id).first() products_with_category = db.query(models.Product).filter(models.Product.category_id == category_id).first()
if groceries_with_category: if products_with_category:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Cannot delete category: groceries are still associated with this category" detail="Cannot delete category: products are still associated with this category"
) )
db.delete(category) db.delete(category)
db.commit() db.commit()
return {"message": "Grocery category deleted successfully"} return {"message": "Grocery category deleted successfully"}
# Grocery endpoints
@app.post("/groceries/", response_model=schemas.Grocery)
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
# Validate category exists
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == grocery.category_id).first()
if category is None:
raise HTTPException(status_code=404, detail="Grocery category not found")
db_grocery = models.Grocery(**grocery.dict())
db.add(db_grocery)
db.commit()
db.refresh(db_grocery)
return db_grocery
@app.get("/groceries/", response_model=List[schemas.Grocery])
def read_groceries(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
groceries = db.query(models.Grocery).offset(skip).limit(limit).all()
return groceries
@app.get("/groceries/{grocery_id}", response_model=schemas.Grocery)
def read_grocery(grocery_id: int, db: Session = Depends(get_db)):
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
if grocery is None:
raise HTTPException(status_code=404, detail="Grocery not found")
return grocery
@app.put("/groceries/{grocery_id}", response_model=schemas.Grocery)
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)):
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
if grocery is None:
raise HTTPException(status_code=404, detail="Grocery not found")
update_data = grocery_update.dict(exclude_unset=True)
# Validate category exists if category_id is being updated
if 'category_id' in update_data:
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == update_data['category_id']).first()
if category is None:
raise HTTPException(status_code=404, detail="Grocery category not found")
for field, value in update_data.items():
setattr(grocery, field, value)
db.commit()
db.refresh(grocery)
return grocery
@app.delete("/groceries/{grocery_id}")
def delete_grocery(grocery_id: int, db: Session = Depends(get_db)):
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
if grocery is None:
raise HTTPException(status_code=404, detail="Grocery not found")
# Check if any products reference this grocery
products_with_grocery = db.query(models.Product).filter(models.Product.grocery_id == grocery_id).first()
if products_with_grocery:
raise HTTPException(
status_code=400,
detail="Cannot delete grocery: products are still associated with this grocery"
)
db.delete(grocery)
db.commit()
return {"message": "Grocery deleted successfully"}
# Shopping Event endpoints # Shopping Event endpoints
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse) @app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)): def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
@@ -584,6 +507,171 @@ def get_shop_stats(db: Session = Depends(get_db)):
# This would need more complex SQL query - placeholder for now # This would need more complex SQL query - placeholder for now
return [] return []
# Related Products endpoints
@app.post("/related-products/", response_model=schemas.RelatedProduct)
def create_related_product(related_product: schemas.RelatedProductCreate, db: Session = Depends(get_db)):
# Validate both products exist
product = db.query(models.Product).filter(models.Product.id == related_product.product_id).first()
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
related = db.query(models.Product).filter(models.Product.id == related_product.related_product_id).first()
if related is None:
raise HTTPException(status_code=404, detail="Related product not found")
# Prevent self-referencing
if related_product.product_id == related_product.related_product_id:
raise HTTPException(status_code=400, detail="A product cannot be related to itself")
# Check if relationship already exists (in either direction)
existing = db.execute(
models.related_products.select().where(
((models.related_products.c.product_id == related_product.product_id) &
(models.related_products.c.related_product_id == related_product.related_product_id)) |
((models.related_products.c.product_id == related_product.related_product_id) &
(models.related_products.c.related_product_id == related_product.product_id))
)
).first()
if existing:
raise HTTPException(status_code=400, detail="Products are already related")
# Insert the relationship
result = db.execute(
models.related_products.insert().values(
product_id=related_product.product_id,
related_product_id=related_product.related_product_id,
relationship_type=related_product.relationship_type
)
)
db.commit()
# Get the created relationship
relationship_id = result.inserted_primary_key[0]
created_relationship = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
return schemas.RelatedProduct(
id=created_relationship.id,
product_id=created_relationship.product_id,
related_product_id=created_relationship.related_product_id,
relationship_type=created_relationship.relationship_type,
created_at=created_relationship.created_at
)
@app.get("/related-products/", response_model=List[schemas.RelatedProduct])
def read_related_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
relationships = db.execute(
models.related_products.select().offset(skip).limit(limit)
).fetchall()
return [
schemas.RelatedProduct(
id=rel.id,
product_id=rel.product_id,
related_product_id=rel.related_product_id,
relationship_type=rel.relationship_type,
created_at=rel.created_at
)
for rel in relationships
]
@app.get("/related-products/product/{product_id}", response_model=List[schemas.Product])
def get_related_products_for_product(product_id: int, db: Session = Depends(get_db)):
# Validate product exists
product = db.query(models.Product).filter(models.Product.id == product_id).first()
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
# Get related products (bidirectional)
related_product_ids = db.execute(
text("""
SELECT CASE
WHEN product_id = :product_id THEN related_product_id
ELSE product_id
END as related_id
FROM related_products
WHERE product_id = :product_id OR related_product_id = :product_id
"""),
{"product_id": product_id}
).fetchall()
if not related_product_ids:
return []
# Get the actual product objects
related_ids = [row.related_id for row in related_product_ids]
related_products = db.query(models.Product).filter(models.Product.id.in_(related_ids)).all()
return related_products
@app.get("/related-products/{relationship_id}", response_model=schemas.RelatedProduct)
def read_related_product(relationship_id: int, db: Session = Depends(get_db)):
relationship = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
if relationship is None:
raise HTTPException(status_code=404, detail="Related product relationship not found")
return schemas.RelatedProduct(
id=relationship.id,
product_id=relationship.product_id,
related_product_id=relationship.related_product_id,
relationship_type=relationship.relationship_type,
created_at=relationship.created_at
)
@app.put("/related-products/{relationship_id}", response_model=schemas.RelatedProduct)
def update_related_product(relationship_id: int, update_data: schemas.RelatedProductUpdate, db: Session = Depends(get_db)):
# Check if relationship exists
existing = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
if existing is None:
raise HTTPException(status_code=404, detail="Related product relationship not found")
# Update the relationship
db.execute(
models.related_products.update().where(models.related_products.c.id == relationship_id).values(
relationship_type=update_data.relationship_type
)
)
db.commit()
# Get the updated relationship
updated_relationship = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
return schemas.RelatedProduct(
id=updated_relationship.id,
product_id=updated_relationship.product_id,
related_product_id=updated_relationship.related_product_id,
relationship_type=updated_relationship.relationship_type,
created_at=updated_relationship.created_at
)
@app.delete("/related-products/{relationship_id}")
def delete_related_product(relationship_id: int, db: Session = Depends(get_db)):
# Check if relationship exists
existing = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
if existing is None:
raise HTTPException(status_code=404, detail="Related product relationship not found")
# Delete the relationship
db.execute(
models.related_products.delete().where(models.related_products.c.id == relationship_id)
)
db.commit()
return {"message": "Related product relationship deleted successfully"}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -17,6 +17,17 @@ shopping_event_products = Table(
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
) )
# Association table for many-to-many self-referential relationship between related products
related_products = Table(
'related_products',
Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('related_product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('relationship_type', String, nullable=True), # Optional: e.g., "size_variant", "brand_variant", "similar"
Column('created_at', DateTime(timezone=True), server_default=func.now())
)
class BrandInShop(Base): class BrandInShop(Base):
__tablename__ = "brands_in_shops" __tablename__ = "brands_in_shops"
@@ -51,27 +62,14 @@ class GroceryCategory(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships # Relationships
groceries = relationship("Grocery", back_populates="category") products = relationship("Product", back_populates="category")
class Grocery(Base):
__tablename__ = "groceries"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
category_id = Column(Integer, ForeignKey("grocery_categories.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
category = relationship("GroceryCategory", back_populates="groceries")
products = relationship("Product", back_populates="grocery")
class Product(Base): class Product(Base):
__tablename__ = "products" __tablename__ = "products"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True) name = Column(String, nullable=False, index=True)
grocery_id = Column(Integer, ForeignKey("groceries.id"), nullable=False) category_id = Column(Integer, ForeignKey("grocery_categories.id"), nullable=False)
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True) brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True)
organic = Column(Boolean, default=False) organic = Column(Boolean, default=False)
weight = Column(Float, nullable=True) # in grams or kg weight = Column(Float, nullable=True) # in grams or kg
@@ -80,10 +78,27 @@ class Product(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships # Relationships
grocery = relationship("Grocery", back_populates="products") category = relationship("GroceryCategory", back_populates="products")
brand = relationship("Brand", back_populates="products") brand = relationship("Brand", back_populates="products")
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products") shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
# Self-referential many-to-many relationship for related products
related_products = relationship(
"Product",
secondary=related_products,
primaryjoin=id == related_products.c.product_id,
secondaryjoin=id == related_products.c.related_product_id,
back_populates="related_to_products"
)
related_to_products = relationship(
"Product",
secondary=related_products,
primaryjoin=id == related_products.c.related_product_id,
secondaryjoin=id == related_products.c.product_id,
back_populates="related_products"
)
class Shop(Base): class Shop(Base):
__tablename__ = "shops" __tablename__ = "shops"

View File

@@ -60,31 +60,10 @@ class GroceryCategory(GroceryCategoryBase):
class Config: class Config:
from_attributes = True from_attributes = True
# Grocery schemas # Product schemas
class GroceryBase(BaseModel):
name: str
category_id: int
class GroceryCreate(GroceryBase):
pass
class GroceryUpdate(BaseModel):
name: Optional[str] = None
category_id: Optional[int] = None
class Grocery(GroceryBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
category: GroceryCategory
class Config:
from_attributes = True
# Base schemas
class ProductBase(BaseModel): class ProductBase(BaseModel):
name: str name: str
grocery_id: int category_id: int
brand_id: Optional[int] = None brand_id: Optional[int] = None
organic: bool = False organic: bool = False
weight: Optional[float] = None weight: Optional[float] = None
@@ -95,7 +74,7 @@ class ProductCreate(ProductBase):
class ProductUpdate(BaseModel): class ProductUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
grocery_id: Optional[int] = None category_id: Optional[int] = None
brand_id: Optional[int] = None brand_id: Optional[int] = None
organic: Optional[bool] = None organic: Optional[bool] = None
weight: Optional[float] = None weight: Optional[float] = None
@@ -105,7 +84,7 @@ class Product(ProductBase):
id: int id: int
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
grocery: Grocery category: GroceryCategory
brand: Optional[Brand] = None brand: Optional[Brand] = None
class Config: class Config:
@@ -142,7 +121,7 @@ class ProductInEvent(BaseModel):
class ProductWithEventData(BaseModel): class ProductWithEventData(BaseModel):
id: int id: int
name: str name: str
grocery: Grocery category: GroceryCategory
brand: Optional[Brand] = None brand: Optional[Brand] = None
organic: bool organic: bool
weight: Optional[float] = None weight: Optional[float] = None
@@ -194,3 +173,29 @@ class ShopStats(BaseModel):
# Update forward references # Update forward references
BrandInShop.model_rebuild() BrandInShop.model_rebuild()
# Related Products schemas
class RelatedProductBase(BaseModel):
product_id: int
related_product_id: int
relationship_type: Optional[str] = None
class RelatedProductCreate(RelatedProductBase):
pass
class RelatedProductUpdate(BaseModel):
relationship_type: Optional[str] = None
class RelatedProduct(RelatedProductBase):
id: int
created_at: datetime
class Config:
from_attributes = True
# Product with related products
class ProductWithRelated(Product):
related_products: List["Product"] = []
class Config:
from_attributes = True

View File

@@ -1,6 +1,6 @@
<mxfile host="65bd71144e"> <mxfile host="Electron" agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/27.0.9 Chrome/134.0.6998.205 Electron/35.4.0 Safari/537.36" version="27.0.9">
<diagram name="Product Tracker Database Schema" id="database-schema"> <diagram name="Product Tracker Database Schema" id="database-schema">
<mxGraphModel dx="1720" dy="739" 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="2317" dy="760" 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> <root>
<mxCell id="0" /> <mxCell id="0" />
<mxCell id="1" parent="0" /> <mxCell id="1" parent="0" />
@@ -24,7 +24,7 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<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"> <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"/> <mxGeometry x="110" y="10" width="320" height="40" as="geometry" />
</mxCell> </mxCell>
<mxCell id="2" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;products&lt;/span&gt;" 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="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;products&lt;/span&gt;" 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="420" y="470" width="180" height="300" as="geometry" /> <mxGeometry x="420" y="470" width="180" height="300" as="geometry" />
@@ -76,7 +76,7 @@
<mxRectangle width="30" height="30" as="alternateBounds" /> <mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="11" 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;whiteSpace=wrap;html=1;" parent="9" vertex="1"> <mxCell id="11" value="categorie_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry"> <mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" /> <mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
@@ -241,7 +241,7 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="70" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shops&lt;/span&gt;" 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="70" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shops&lt;/span&gt;" 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="80" y="80" width="180" height="210" as="geometry"/> <mxGeometry x="120" y="90" width="180" height="210" as="geometry" />
</mxCell> </mxCell>
<mxCell id="71" 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="70" vertex="1"> <mxCell id="71" 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="70" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry" /> <mxGeometry y="30" width="180" height="30" as="geometry" />
@@ -322,10 +322,10 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="95" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_event_products&lt;/span&gt;" 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="95" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_event_products&lt;/span&gt;" 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="810" y="200" width="240" height="180" as="geometry"/> <mxGeometry x="760" y="210" width="220" height="180" as="geometry" />
</mxCell> </mxCell>
<mxCell id="96" 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="95" vertex="1"> <mxCell id="96" 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="95" vertex="1">
<mxGeometry y="30" width="240" height="30" as="geometry"/> <mxGeometry y="30" width="220" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="97" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1"> <mxCell id="97" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
@@ -333,12 +333,12 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1"> <mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry"> <mxGeometry x="30" width="190" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/> <mxRectangle width="190" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="99" 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=0;" parent="95" vertex="1"> <mxCell id="99" 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=0;" parent="95" vertex="1">
<mxGeometry y="60" width="240" height="30" as="geometry"/> <mxGeometry y="60" width="220" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="100" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1"> <mxCell id="100" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
@@ -346,12 +346,12 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1"> <mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry"> <mxGeometry x="30" width="190" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/> <mxRectangle width="190" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="102" 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=0;" parent="95" vertex="1"> <mxCell id="102" 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=0;" parent="95" vertex="1">
<mxGeometry y="90" width="240" height="30" as="geometry"/> <mxGeometry y="90" width="220" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="103" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1"> <mxCell id="103" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
@@ -359,12 +359,12 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="104" 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;whiteSpace=wrap;html=1;" parent="102" vertex="1"> <mxCell id="104" 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;whiteSpace=wrap;html=1;" parent="102" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry"> <mxGeometry x="30" width="190" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/> <mxRectangle width="190" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="105" 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=0;" parent="95" vertex="1"> <mxCell id="105" 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=0;" parent="95" vertex="1">
<mxGeometry y="120" width="240" height="30" as="geometry"/> <mxGeometry y="120" width="220" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="106" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1"> <mxCell id="106" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
@@ -372,12 +372,12 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1"> <mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry"> <mxGeometry x="30" width="190" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/> <mxRectangle width="190" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="108" 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=0;" parent="95" vertex="1"> <mxCell id="108" 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=0;" parent="95" vertex="1">
<mxGeometry y="150" width="240" height="30" as="geometry"/> <mxGeometry y="150" width="220" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="109" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="108" vertex="1"> <mxCell id="109" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="108" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
@@ -385,12 +385,12 @@
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1"> <mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry"> <mxGeometry x="30" width="190" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/> <mxRectangle width="190" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="114" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands&lt;/span&gt;" 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="114" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands&lt;/span&gt;" 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="-430" y="414" width="180" height="150" as="geometry"/> <mxGeometry x="-410" y="400" width="180" height="150" as="geometry" />
</mxCell> </mxCell>
<mxCell id="115" 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="114" vertex="1"> <mxCell id="115" 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="114" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry" /> <mxGeometry y="30" width="180" height="30" as="geometry" />
@@ -451,83 +451,8 @@
<Array as="points" /> <Array as="points" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="131" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;groceries&lt;/span&gt;" 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="90" y="700" width="180" height="180" as="geometry"/>
</mxCell>
<mxCell id="132" 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="131" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="133" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="134" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="135" 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=0;" parent="131" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="136" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="137" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="138" 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=0;" parent="131" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="139" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="140" value="category_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="138" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="141" 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=0;" parent="131" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="141" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="143" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="141" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="144" 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=0;" parent="131" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="145" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="144" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="146" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="144" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="147" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="132" target="9" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="280" y="535" as="sourcePoint"/>
<mxPoint x="430" y="585" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="148" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;grocerie_categories&lt;/span&gt;" 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="148" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;grocerie_categories&lt;/span&gt;" 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="-210" y="715" width="180" height="150" as="geometry"/> <mxGeometry x="30" y="580" width="180" height="150" as="geometry" />
</mxCell> </mxCell>
<mxCell id="149" 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="148" vertex="1"> <mxCell id="149" 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="148" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry" /> <mxGeometry y="30" width="180" height="30" as="geometry" />
@@ -581,95 +506,190 @@
<mxRectangle width="150" height="30" as="alternateBounds" /> <mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="149" target="138" edge="1"> <mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="149" target="9" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="270" y="785" as="sourcePoint" /> <mxPoint x="270" y="785" as="sourcePoint" />
<mxPoint x="80" y="835" as="targetPoint"/> <mxPoint x="90" y="805" as="targetPoint" />
<Array as="points" /> <Array as="points" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="199" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" edge="1" parent="1" source="71" target="187"> <mxCell id="199" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="187" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="280" y="755" as="sourcePoint" /> <mxPoint x="280" y="755" as="sourcePoint" />
<mxPoint x="430" y="615" as="targetPoint" /> <mxPoint x="430" y="615" as="targetPoint" />
<Array as="points" /> <Array as="points" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="200" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" edge="1" parent="1" source="115" target="190"> <mxCell id="200" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="115" target="190" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry"> <mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="90" y="135" as="sourcePoint" /> <mxPoint x="90" y="135" as="sourcePoint" />
<mxPoint x="-21" y="352" as="targetPoint" /> <mxPoint x="-21" y="352" as="targetPoint" />
<Array as="points" /> <Array as="points" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="183" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands_in_shops&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1"> <mxCell id="183" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands_in_shops&lt;/span&gt;" 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="-180" y="220" width="180" height="180" as="geometry"/> <mxGeometry x="-160" y="210" width="180" height="180" as="geometry" />
</mxCell> </mxCell>
<mxCell id="184" 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;" vertex="1" parent="183"> <mxCell id="184" 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="183" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry" /> <mxGeometry y="30" width="180" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="185" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="184"> <mxCell id="185" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="184" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" /> <mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="186" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="184"> <mxCell id="186" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="184" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry"> <mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" /> <mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="187" 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=0;" vertex="1" parent="183"> <mxCell id="187" 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=0;" parent="183" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry" /> <mxGeometry y="60" width="180" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="188" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="187"> <mxCell id="188" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="187" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" /> <mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="189" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: nowrap;&quot;&gt;shop_id: INTEGER&lt;/span&gt;" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="187"> <mxCell id="189" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: nowrap;&quot;&gt;shop_id: INTEGER&lt;/span&gt;" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="187" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry"> <mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" /> <mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="190" 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=0;" vertex="1" parent="183"> <mxCell id="190" 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=0;" parent="183" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry" /> <mxGeometry y="90" width="180" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="191" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="190"> <mxCell id="191" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="190" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" /> <mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="192" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="190"> <mxCell id="192" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="190" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry"> <mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" /> <mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="193" 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=0;" vertex="1" parent="183"> <mxCell id="193" 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=0;" parent="183" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry" /> <mxGeometry y="120" width="180" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="194" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="193"> <mxCell id="194" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="193" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" /> <mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="195" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="193"> <mxCell id="195" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="193" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry"> <mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" /> <mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="196" 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=0;" vertex="1" parent="183"> <mxCell id="196" 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=0;" parent="183" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry" /> <mxGeometry y="150" width="180" height="30" as="geometry" />
</mxCell> </mxCell>
<mxCell id="197" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="196"> <mxCell id="197" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="196" vertex="1">
<mxGeometry width="30" height="30" as="geometry"> <mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" /> <mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="198" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="196"> <mxCell id="198" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="196" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry"> <mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" /> <mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry> </mxGeometry>
</mxCell> </mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-200" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;related_products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
<mxGeometry x="790" y="470" width="180" height="210" as="geometry" />
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-201" 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;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
<mxGeometry y="30" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-202" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-201">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-203" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-201">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-204" 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=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
<mxGeometry y="60" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-205" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-204">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-206" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-204">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-214" 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=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
<mxGeometry y="90" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-215" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-214">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-216" value="product_id" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-214">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-217" 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=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
<mxGeometry y="120" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-218" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-217">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-219" value="product_id_ref" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-217">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-207" 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=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
<mxGeometry y="150" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-208" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-207">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-209" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-207">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-210" 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=0;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-200">
<mxGeometry y="180" width="180" height="30" as="geometry" />
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-211" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-210">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-212" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="rvE4wdXwnSLMpUZ5b23a-210">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-220" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="3" target="rvE4wdXwnSLMpUZ5b23a-214">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="810" as="sourcePoint" />
<mxPoint x="910" y="790" as="targetPoint" />
<Array as="points" />
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-221" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="3" target="rvE4wdXwnSLMpUZ5b23a-217">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="710" y="580" as="sourcePoint" />
<mxPoint x="880" y="700" as="targetPoint" />
<Array as="points" />
</mxGeometry>
</mxCell>
</root> </root>
</mxGraphModel> </mxGraphModel>
</diagram> </diagram>

View File

@@ -5,7 +5,6 @@ import ShopList from './components/ShopList';
import ProductList from './components/ProductList'; import ProductList from './components/ProductList';
import ShoppingEventList from './components/ShoppingEventList'; import ShoppingEventList from './components/ShoppingEventList';
import BrandList from './components/BrandList'; import BrandList from './components/BrandList';
import GroceryList from './components/GroceryList';
import GroceryCategoryList from './components/GroceryCategoryList'; import GroceryCategoryList from './components/GroceryCategoryList';
import ImportExportModal from './components/ImportExportModal'; import ImportExportModal from './components/ImportExportModal';
@@ -71,16 +70,6 @@ function Navigation({ onImportExportClick }: { onImportExportClick: () => void }
> >
Brands Brands
</Link> </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 <Link
to="/categories" to="/categories"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${ className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
@@ -130,7 +119,6 @@ function App() {
<Route path="/shops" element={<ShopList />} /> <Route path="/shops" element={<ShopList />} />
<Route path="/products" element={<ProductList />} /> <Route path="/products" element={<ProductList />} />
<Route path="/brands" element={<BrandList />} /> <Route path="/brands" element={<BrandList />} />
<Route path="/groceries" element={<GroceryList />} />
<Route path="/categories" element={<GroceryCategoryList />} /> <Route path="/categories" element={<GroceryCategoryList />} />
</Routes> </Routes>
</div> </div>

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 React, { useState, useEffect, useCallback } from 'react';
import { productApi, brandApi, groceryApi } from '../services/api'; import { productApi, brandApi, groceryCategoryApi } from '../services/api';
import { Product, Brand, Grocery } from '../types'; import { Product, Brand, GroceryCategory } from '../types';
interface AddProductModalProps { interface AddProductModalProps {
isOpen: boolean; isOpen: boolean;
@@ -11,7 +11,7 @@ interface AddProductModalProps {
interface ProductFormData { interface ProductFormData {
name: string; name: string;
grocery_id?: number; category_id?: number;
brand_id?: number; brand_id?: number;
organic: boolean; organic: boolean;
weight?: number; weight?: number;
@@ -21,24 +21,24 @@ interface ProductFormData {
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => { const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
const [formData, setFormData] = useState<ProductFormData>({ const [formData, setFormData] = useState<ProductFormData>({
name: '', name: '',
grocery_id: undefined, category_id: undefined,
brand_id: undefined, brand_id: undefined,
organic: false, organic: false,
weight: undefined, weight: undefined,
weight_unit: 'piece' weight_unit: 'piece'
}); });
const [brands, setBrands] = useState<Brand[]>([]); const [brands, setBrands] = useState<Brand[]>([]);
const [groceries, setGroceries] = useState<Grocery[]>([]); const [categories, setCategories] = useState<GroceryCategory[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l']; const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
// Fetch brands and groceries when modal opens // Fetch brands and categories when modal opens
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
fetchBrands(); fetchBrands();
fetchGroceries(); fetchCategories();
} }
}, [isOpen]); }, [isOpen]);
@@ -51,12 +51,12 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
} }
}; };
const fetchGroceries = async () => { const fetchCategories = async () => {
try { try {
const response = await groceryApi.getAll(); const response = await groceryCategoryApi.getAll();
setGroceries(response.data); setCategories(response.data);
} catch (err) { } 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) { if (editProduct) {
setFormData({ setFormData({
name: editProduct.name, name: editProduct.name,
grocery_id: editProduct.grocery_id, category_id: editProduct.category_id,
brand_id: editProduct.brand_id, brand_id: editProduct.brand_id,
organic: editProduct.organic, organic: editProduct.organic,
weight: editProduct.weight, weight: editProduct.weight,
@@ -75,7 +75,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
// Reset form for adding new product // Reset form for adding new product
setFormData({ setFormData({
name: '', name: '',
grocery_id: undefined, category_id: undefined,
brand_id: undefined, brand_id: undefined,
organic: false, organic: false,
weight: undefined, weight: undefined,
@@ -85,29 +85,9 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
setError(''); setError('');
}, [editProduct, isOpen]); }, [editProduct, isOpen]);
useEffect(() => { const handleSubmit = useCallback(async (e: React.FormEvent) => {
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) => {
e.preventDefault(); 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'); setError('Please fill in all required fields with valid values');
return; return;
} }
@@ -118,7 +98,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
const productData = { const productData = {
name: formData.name.trim(), name: formData.name.trim(),
grocery_id: formData.grocery_id!, category_id: formData.category_id!,
brand_id: formData.brand_id || undefined, brand_id: formData.brand_id || undefined,
organic: formData.organic, organic: formData.organic,
weight: formData.weight || undefined, weight: formData.weight || undefined,
@@ -136,7 +116,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
// Reset form // Reset form
setFormData({ setFormData({
name: '', name: '',
grocery_id: undefined, category_id: undefined,
brand_id: undefined, brand_id: undefined,
organic: false, organic: false,
weight: undefined, weight: undefined,
@@ -151,15 +131,35 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
} finally { } finally {
setLoading(false); 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 handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target; const { name, value, type } = e.target;
setFormData(prev => ({ setFormData(prev => ({
...prev, ...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked [name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
: type === 'number' ? (value === '' ? undefined : Number(value)) : 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 : value
})); }));
}; };
@@ -208,21 +208,21 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
</div> </div>
<div> <div>
<label htmlFor="grocery_id" className="block text-sm font-medium text-gray-700"> <label htmlFor="category_id" className="block text-sm font-medium text-gray-700">
Grocery Type * Category *
</label> </label>
<select <select
id="grocery_id" id="category_id"
name="grocery_id" name="category_id"
value={formData.grocery_id || ''} value={formData.category_id || ''}
onChange={handleChange} onChange={handleChange}
required 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" 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> <option value="">Select a category</option>
{groceries.map(grocery => ( {categories.map(category => (
<option key={grocery.id} value={grocery.id}> <option key={category.id} value={category.id}>
{grocery.name} ({grocery.category.name}) {category.name}
</option> </option>
))} ))}
</select> </select>

View File

@@ -101,6 +101,38 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
} }
}, [isOpen, loadEventData]); }, [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(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) return; if (!isOpen) return;
@@ -126,7 +158,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown);
}; };
}, [isOpen, formData, selectedProducts, loading, onClose]); }, [isOpen, formData, selectedProducts, loading, onClose, handleSubmit]);
// Update total amount whenever selectedProducts changes // Update total amount whenever selectedProducts changes
useEffect(() => { useEffect(() => {
@@ -208,38 +240,6 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
setSelectedProducts(selectedProducts.filter((_, i) => i !== index)); 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 getProductName = (id: number) => {
const product = products.find(p => p.id === id); const product = products.find(p => p.id === id);
if (!product) return 'Unknown'; if (!product) return 'Unknown';
@@ -349,7 +349,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
<option value={0}>Select a product</option> <option value={0}>Select a product</option>
{Object.entries( {Object.entries(
getFilteredProducts().reduce((groups, product) => { getFilteredProducts().reduce((groups, product) => {
const category = product.grocery.category.name; const category = product.category.name;
if (!groups[category]) { if (!groups[category]) {
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 React, { useState, useEffect } from 'react';
import Papa from 'papaparse'; import Papa from 'papaparse';
import { Brand, Grocery, GroceryCategory, Product } from '../types'; import { Brand, GroceryCategory, Product } from '../types';
import { brandApi, groceryApi, groceryCategoryApi, productApi } from '../services/api'; import { brandApi, groceryCategoryApi, productApi } from '../services/api';
interface ImportExportModalProps { interface ImportExportModalProps {
isOpen: boolean; isOpen: boolean;
@@ -9,7 +9,7 @@ interface ImportExportModalProps {
onDataChanged: () => void; onDataChanged: () => void;
} }
type EntityType = 'brands' | 'groceries' | 'categories' | 'products'; type EntityType = 'brands' | 'categories' | 'products';
interface ImportResult { interface ImportResult {
success: number; success: number;
@@ -84,29 +84,14 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
filename = 'grocery_categories.csv'; filename = 'grocery_categories.csv';
break; 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': case 'products':
const productsResponse = await productApi.getAll(); const productsResponse = await productApi.getAll();
data = productsResponse.data.map((product: Product) => ({ data = productsResponse.data.map((product: Product) => ({
id: product.id, id: product.id,
name: product.name, name: product.name,
organic: product.organic, organic: product.organic,
grocery_id: product.grocery.id, category_id: product.category.id,
grocery_name: product.grocery.name, category_name: product.category.name,
category_id: product.grocery.category.id,
category_name: product.grocery.category.name,
brand_id: product.brand?.id || null, brand_id: product.brand?.id || null,
brand_name: product.brand?.name || null, brand_name: product.brand?.name || null,
weight: product.weight, weight: product.weight,
@@ -194,16 +179,9 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
return; 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 (selectedEntity === 'products') {
if (!row.grocery_name || typeof row.grocery_name !== 'string' || row.grocery_name.trim().length === 0) { if (!row.category_name || typeof row.category_name !== 'string' || row.category_name.trim().length === 0) {
errors.push(`Row ${rowNum}: Grocery name is required for products`); errors.push(`Row ${rowNum}: Category name is required for products`);
return; return;
} }
if (row.organic !== undefined && typeof row.organic !== 'boolean' && row.organic !== 'true' && row.organic !== 'false') { 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({ valid.push({
name: row.name.trim(), name: row.name.trim(),
category_name: selectedEntity === 'groceries' ? row.category_name?.trim() : undefined, category_name: selectedEntity === 'products' ? row.category_name?.trim() : undefined,
grocery_name: selectedEntity === 'products' ? row.grocery_name?.trim() : undefined,
organic: selectedEntity === 'products' ? (row.organic === 'true' || row.organic === true) : undefined, organic: selectedEntity === 'products' ? (row.organic === 'true' || row.organic === true) : undefined,
brand_name: selectedEntity === 'products' && row.brand_name ? row.brand_name.trim() : undefined, brand_name: selectedEntity === 'products' && row.brand_name ? row.brand_name.trim() : undefined,
weight: selectedEntity === 'products' && row.weight ? parseFloat(row.weight) : undefined, weight: selectedEntity === 'products' && row.weight ? parseFloat(row.weight) : undefined,
@@ -253,19 +230,12 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
let failedCount = 0; let failedCount = 0;
const importErrors: string[] = [...errors]; const importErrors: string[] = [...errors];
// Get categories for grocery import // Get categories and brands for product import
let categories: GroceryCategory[] = []; 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[] = []; let brands: Brand[] = [];
if (selectedEntity === 'products') { if (selectedEntity === 'products') {
const groceriesResponse = await groceryApi.getAll(); const categoriesResponse = await groceryCategoryApi.getAll();
groceries = groceriesResponse.data; categories = categoriesResponse.data;
const brandsResponse = await brandApi.getAll(); const brandsResponse = await brandApi.getAll();
brands = brandsResponse.data; brands = brandsResponse.data;
} }
@@ -282,24 +252,11 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
await groceryCategoryApi.create({ name: item.name }); await groceryCategoryApi.create({ name: item.name });
break; break;
case 'groceries': case 'products':
const category = categories.find(c => c.name.toLowerCase() === item.category_name.toLowerCase()); const category = categories.find(c => c.name.toLowerCase() === item.category_name.toLowerCase());
if (!category) { if (!category) {
failedCount++; failedCount++;
importErrors.push(`Grocery "${item.name}": Category "${item.category_name}" not found`); importErrors.push(`Product "${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`);
continue; continue;
} }
@@ -317,10 +274,10 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
await productApi.create({ await productApi.create({
name: item.name, name: item.name,
organic: item.organic || false, organic: item.organic || false,
grocery_id: grocery.id, category_id: category.id,
brand_id: brandId || undefined, brand_id: brandId || undefined,
weight: item.weight, weight: item.weight,
weight_unit: item.weight_unit || '' weight_unit: item.weight_unit || 'piece'
}); });
break; break;
} }
@@ -371,10 +328,8 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
case 'brands': case 'brands':
case 'categories': case 'categories':
return 'name'; return 'name';
case 'groceries':
return 'name,category_name';
case 'products': 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: default:
return ''; return '';
} }
@@ -446,7 +401,6 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
> >
<option value="brands">Brands</option> <option value="brands">Brands</option>
<option value="categories">Grocery Categories</option> <option value="categories">Grocery Categories</option>
<option value="groceries">Groceries</option>
<option value="products">Products</option> <option value="products">Products</option>
</select> </select>
</div> </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. This will download all {selectedEntity} as a CSV file that you can open in Excel or other spreadsheet applications.
</p> </p>
<p className="text-sm text-blue-700"> <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> </p>
</div> </div>
@@ -487,14 +441,9 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
<p className="text-sm text-yellow-700 mb-2"> <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. <strong>Note:</strong> ID, created_at, and updated_at fields are optional for import and will be ignored if present.
</p> </p>
{selectedEntity === 'groceries' && (
<p className="text-sm text-yellow-700">
<strong>Groceries:</strong> Category names must match existing grocery categories exactly.
</p>
)}
{selectedEntity === 'products' && ( {selectedEntity === 'products' && (
<p className="text-sm text-yellow-700"> <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> </p>
)} )}
</div> </div>

View File

@@ -97,13 +97,9 @@ const ProductList: React.FC = () => {
aValue = a.name; aValue = a.name;
bValue = b.name; bValue = b.name;
break; break;
case 'grocery':
aValue = a.grocery.name;
bValue = b.grocery.name;
break;
case 'category': case 'category':
aValue = a.grocery.category.name; aValue = a.category.name;
bValue = b.grocery.category.name; bValue = b.category.name;
break; break;
case 'brand': case 'brand':
aValue = a.brand?.name || ''; aValue = a.brand?.name || '';
@@ -216,15 +212,6 @@ const ProductList: React.FC = () => {
{getSortIcon('name')} {getSortIcon('name')}
</div> </div>
</th> </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 <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" 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')} onClick={() => handleSort('category')}
@@ -265,11 +252,8 @@ const ProductList: React.FC = () => {
{product.name} {product.organic ? '🌱' : ''} {product.name} {product.organic ? '🌱' : ''}
</div> </div>
</td> </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"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.grocery.category.name} {product.category.name}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900"> <td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.brand ? product.brand.name : '-'} {product.brand ? product.brand.name : '-'}

View File

@@ -104,7 +104,6 @@ const ShoppingEventList: React.FC = () => {
const handleItemsHover = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => { const handleItemsHover = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
if (event.products.length === 0) return; if (event.products.length === 0) return;
const rect = mouseEvent.currentTarget.getBoundingClientRect();
const popupWidth = 384; // max-w-md is approximately 384px const popupWidth = 384; // max-w-md is approximately 384px
const popupHeight = 300; // max height we set const popupHeight = 300; // max height we set
@@ -136,7 +135,6 @@ const ShoppingEventList: React.FC = () => {
if (event.products.length === 0) return; if (event.products.length === 0) return;
mouseEvent.stopPropagation(); mouseEvent.stopPropagation();
const rect = mouseEvent.currentTarget.getBoundingClientRect();
const popupWidth = 384; // max-w-md is approximately 384px const popupWidth = 384; // max-w-md is approximately 384px
const popupHeight = 300; // max height we set const popupHeight = 300; // max height we set
@@ -442,7 +440,7 @@ const ShoppingEventList: React.FC = () => {
{product.name} {product.organic ? '🌱' : ''} {product.name} {product.organic ? '🌱' : ''}
</div> </div>
<div className="text-xs text-gray-600"> <div className="text-xs text-gray-600">
{product.grocery?.category?.name || 'Unknown category'} {product.category?.name || 'Unknown category'}
</div> </div>
{product.brand && ( {product.brand && (
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">

View File

@@ -1,4 +1,4 @@
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate, BrandInShop, BrandInShopCreate } from '../types'; import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, GroceryCategory, GroceryCategoryCreate, BrandInShop, BrandInShopCreate } from '../types';
// Use different API URLs based on environment // Use different API URLs based on environment
const API_BASE_URL = process.env.NODE_ENV === 'production' const API_BASE_URL = process.env.NODE_ENV === 'production'
@@ -83,16 +83,6 @@ export const shoppingEventApi = {
delete: (id: number) => api.delete(`/shopping-events/${id}`), delete: (id: number) => api.delete(`/shopping-events/${id}`),
}; };
// Grocery API functions
export const groceryApi = {
getAll: () => api.get<Grocery[]>('/groceries/'),
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
update: (id: number, grocery: Partial<GroceryCreate>) =>
api.put<Grocery>(`/groceries/${id}`, grocery),
delete: (id: number) => api.delete(`/groceries/${id}`),
};
// Statistics API functions // Statistics API functions
export const statsApi = { export const statsApi = {
getCategories: () => api.get('/stats/categories'), getCategories: () => api.get('/stats/categories'),

View File

@@ -20,25 +20,11 @@ export interface GroceryCategoryCreate {
name: string; name: string;
} }
export interface Grocery {
id: number;
name: string;
category_id: number;
created_at: string;
updated_at?: string;
category: GroceryCategory;
}
export interface GroceryCreate {
name: string;
category_id: number;
}
export interface Product { export interface Product {
id: number; id: number;
name: string; name: string;
grocery_id: number; category_id: number;
grocery: Grocery; category: GroceryCategory;
brand_id?: number; brand_id?: number;
brand?: Brand; brand?: Brand;
organic: boolean; organic: boolean;
@@ -50,7 +36,7 @@ export interface Product {
export interface ProductCreate { export interface ProductCreate {
name: string; name: string;
grocery_id: number; category_id: number;
brand_id?: number; brand_id?: number;
organic: boolean; organic: boolean;
weight?: number; weight?: number;
@@ -81,7 +67,7 @@ export interface ProductInEvent {
export interface ProductWithEventData { export interface ProductWithEventData {
id: number; id: number;
name: string; name: string;
grocery: Grocery; category: GroceryCategory;
brand?: Brand; brand?: Brand;
organic: boolean; organic: boolean;
weight?: number; weight?: number;