remove intermediate grocery table and add related_products feature
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -215,6 +215,9 @@ $RECYCLE.BIN/
|
|||||||
# Linux
|
# Linux
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
# Temporary files starting with .$
|
||||||
|
.$*
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
149
README.md
149
README.md
@@ -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) │
|
│ │ (Association Table) │
|
||||||
│ │
|
│ │ │
|
||||||
│ • id │
|
│ │ • id │
|
||||||
│ • shopping_event_id │
|
│ │ • shopping_event_id │
|
||||||
│ • product_id │
|
│ │ • product_id │
|
||||||
│ • amount │
|
│ │ • amount │
|
||||||
│ • price │
|
│ │ • price │
|
||||||
└─────────────────────────────┘
|
│ └─────────────────────────────┘
|
||||||
│
|
│ │
|
||||||
│ N:1
|
│ │ N:1
|
||||||
▼
|
│ ▼
|
||||||
┌─────────────────┐ 1:N ┌─────────────────┐
|
│ ┌─────────────────┐ 1:N ┌─────────────────┐
|
||||||
│ Shops │ ────────→ │ Shopping Events │
|
│ │ Shops │ ────────→ │ Shopping Events │
|
||||||
│ │ │ │
|
│ │ │ │ │
|
||||||
│ • id │ │ • id │
|
│ │ • id │ │ • id │
|
||||||
│ • name │ │ • shop_id │
|
│ │ • name │ │ • shop_id │
|
||||||
│ • city │ │ • date │
|
│ │ • city │ │ • date │
|
||||||
│ • address │ │ • total_amount │
|
│ │ • address │ │ • total_amount │
|
||||||
│ • created_at │ │ • notes │
|
│ │ • created_at │ │ • notes │
|
||||||
│ • updated_at │ │ • created_at │
|
│ │ • updated_at │ │ • created_at │
|
||||||
└─────────────────┘ │ • updated_at │
|
│ └─────────────────┘ │ • updated_at │
|
||||||
└─────────────────┘
|
│ │ └─────────────────┘
|
||||||
|
│ │
|
||||||
|
│ │ N:M
|
||||||
|
│ ▼
|
||||||
|
│ ┌─────────────────────────────┐
|
||||||
|
└────────────→ │ Brands in Shops │
|
||||||
|
│ (Association Table) │
|
||||||
|
│ │
|
||||||
|
│ • id │
|
||||||
|
│ • shop_id │
|
||||||
|
│ • brand_id │
|
||||||
|
│ • 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
|
||||||
|
|
||||||
|
|||||||
274
backend/main.py
274
backend/main.py
@@ -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)
|
||||||
@@ -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,9 +78,26 @@ 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"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -193,4 +172,30 @@ class ShopStats(BaseModel):
|
|||||||
avg_per_visit: float
|
avg_per_visit: float
|
||||||
|
|
||||||
# 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
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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,7 +131,27 @@ 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;
|
||||||
@@ -159,7 +159,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
...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>
|
||||||
|
|||||||
@@ -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] = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -281,25 +251,12 @@ const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose,
|
|||||||
case 'categories':
|
case 'categories':
|
||||||
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>
|
||||||
|
|||||||
@@ -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 : '-'}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user