Compare commits

...

8 Commits

Author SHA1 Message Date
f88a931008 - add grocery category
- add Dockerfile
2025-05-26 21:55:49 +02:00
6118415f05 cleanup 2025-05-26 21:13:49 +02:00
7e24d58a94 add grocery to product 2025-05-26 21:05:15 +02:00
25c09dfecc add Brand Management 2025-05-26 20:44:15 +02:00
d27871160e rename grocery to product 2025-05-26 20:20:21 +02:00
1b984d18d9 add 🌱 for organic groceries 2025-05-26 12:36:39 +02:00
71b36f7749 create script for test data 2025-05-26 11:10:58 +02:00
5cd9d65e00 add updated_at fields 2025-05-26 11:10:42 +02:00
36 changed files with 3289 additions and 694 deletions

370
DOCKER_DEPLOYMENT.md Normal file
View File

@ -0,0 +1,370 @@
# Docker Deployment Guide
This guide explains how to deploy your Groceries/Product Tracker application using Docker Compose.
## Prerequisites
- Docker Engine 20.10+
- Docker Compose 2.0+
- Git (for cloning from your Gitea repository)
## Quick Start
1. **Clone your repository from Gitea:**
```bash
git clone <your-gitea-repo-url>
cd groceries
```
2. **Set up environment variables:**
```bash
cp docker.env.example .env
# Edit .env with your production values
nano .env
```
3. **Build and start all services:**
```bash
docker-compose up -d
```
4. **Access your application:**
- Frontend: http://localhost
- Backend API: http://localhost:8000
- API Documentation: http://localhost:8000/docs
## Architecture
The Docker Compose setup includes:
- **PostgreSQL Database** (port 5432)
- **FastAPI Backend** (port 8000)
- **React Frontend with Nginx** (port 80)
## Environment Configuration
### Required Environment Variables
Create a `.env` file in the root directory:
```bash
# Database Configuration
DB_PASSWORD=your_secure_database_password
# Backend Configuration
SECRET_KEY=your-super-secret-key-for-jwt-tokens-make-it-very-long-and-random
DEBUG=False
```
### Optional Configuration
You can override default ports by adding to your `.env` file:
```bash
# Custom ports (optional)
FRONTEND_PORT=3000
BACKEND_PORT=8001
DB_PORT=5433
```
Then update the docker-compose.yml ports section accordingly.
## Deployment Commands
### Development Deployment
```bash
# Start all services
docker-compose up
# Start in background
docker-compose up -d
# View logs
docker-compose logs -f
# View logs for specific service
docker-compose logs -f backend
```
### Production Deployment
1. **Set production environment:**
```bash
export COMPOSE_FILE=docker-compose.yml
export COMPOSE_PROJECT_NAME=groceries_prod
```
2. **Use production environment file:**
```bash
cp docker.env.example .env.prod
# Edit .env.prod with production values
docker-compose --env-file .env.prod up -d
```
3. **Run database migrations:**
```bash
docker-compose exec backend alembic upgrade head
```
## Database Management
### Initial Setup
The database will be automatically created when you first start the services. To run migrations:
```bash
# Run migrations
docker-compose exec backend alembic upgrade head
# Create new migration (if you've made model changes)
docker-compose exec backend alembic revision --autogenerate -m "Description"
```
### Backup and Restore
```bash
# Backup database
docker-compose exec db pg_dump -U product_user product_tracker > backup.sql
# Restore database
docker-compose exec -T db psql -U product_user product_tracker < backup.sql
```
## Service Management
### Individual Service Control
```bash
# Restart specific service
docker-compose restart backend
# Rebuild and restart service
docker-compose up -d --build backend
# Scale services (if needed)
docker-compose up -d --scale backend=2
```
### Updating the Application
```bash
# Pull latest code from Gitea
git pull origin main
# Rebuild and restart services
docker-compose down
docker-compose up -d --build
```
## Monitoring and Logs
### View Logs
```bash
# All services
docker-compose logs -f
# Specific service
docker-compose logs -f backend
docker-compose logs -f frontend
docker-compose logs -f db
# Last 100 lines
docker-compose logs --tail=100 backend
```
### Health Checks
The services include health checks. Check status:
```bash
docker-compose ps
```
### Resource Usage
```bash
# View resource usage
docker stats
# View specific containers
docker stats groceries_backend groceries_frontend groceries_db
```
## Troubleshooting
### Common Issues
1. **Port conflicts:**
```bash
# Check what's using the port
lsof -i :80
lsof -i :8000
lsof -i :5432
# Change ports in .env file or docker-compose.yml
```
2. **Database connection issues:**
```bash
# Check database logs
docker-compose logs db
# Test database connection
docker-compose exec db psql -U product_user -d product_tracker -c "SELECT 1;"
```
3. **Backend not starting:**
```bash
# Check backend logs
docker-compose logs backend
# Exec into container to debug
docker-compose exec backend bash
```
4. **Frontend not loading:**
```bash
# Check nginx logs
docker-compose logs frontend
# Verify build completed successfully
docker-compose exec frontend ls -la /usr/share/nginx/html
```
### Reset Everything
```bash
# Stop and remove all containers, networks, and volumes
docker-compose down -v
# Remove all images
docker-compose down --rmi all
# Start fresh
docker-compose up -d --build
```
## Security Considerations
### Production Security
1. **Change default passwords:**
- Update `DB_PASSWORD` in `.env`
- Generate a strong `SECRET_KEY`
2. **Use HTTPS:**
- Add SSL certificates
- Configure nginx for HTTPS
- Update docker-compose.yml with SSL configuration
3. **Network security:**
- Remove port mappings for internal services
- Use Docker secrets for sensitive data
- Configure firewall rules
4. **Regular updates:**
- Keep Docker images updated
- Update application dependencies
- Monitor security advisories
### Example Production docker-compose.yml
For production, consider:
```yaml
# Remove port mappings for internal services
services:
db:
# Remove: ports: - "5432:5432"
backend:
# Remove: ports: - "8000:8000"
frontend:
ports:
- "443:443" # HTTPS
- "80:80" # HTTP redirect
```
## Gitea Integration
### Automated Deployment
You can set up automated deployment from your Gitea repository:
1. **Create a webhook** in your Gitea repository
2. **Set up a deployment script** on your server:
```bash
#!/bin/bash
# deploy.sh
cd /path/to/your/app
git pull origin main
docker-compose down
docker-compose up -d --build
```
3. **Configure webhook** to trigger the deployment script
### CI/CD Pipeline
Consider setting up Gitea Actions or external CI/CD:
```yaml
# .gitea/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to server
run: |
# Your deployment commands
docker-compose up -d --build
```
## Performance Optimization
### Production Optimizations
1. **Use multi-stage builds** (already implemented)
2. **Enable gzip compression** in nginx
3. **Add caching headers** (already configured)
4. **Use connection pooling** for database
5. **Configure resource limits:**
```yaml
services:
backend:
deploy:
resources:
limits:
memory: 512M
cpus: '0.5'
```
## Backup Strategy
### Automated Backups
```bash
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
docker-compose exec -T db pg_dump -U product_user product_tracker > "backup_${DATE}.sql"
# Upload to cloud storage or remote location
```
Set up a cron job:
```bash
# Run daily at 2 AM
0 2 * * * /path/to/backup.sh
```
This setup provides a robust, production-ready deployment of your groceries application using Docker Compose!

View File

@ -1,6 +1,6 @@
# 🚀 Quick Start Guide
Get your Grocery Tracker up and running in minutes!
Get your Product Tracker up and running in minutes!
## Prerequisites
@ -62,8 +62,8 @@ This will:
## First Steps
1. **Add a Shop**: Go to "Shops" and add your first grocery store
2. **Add Groceries**: Go to "Groceries" and add some items
1. **Add a Shop**: Go to "Shops" and add your first store
2. **Add Products**: Go to "Products" and add some items
3. **Record a Purchase**: Use "Add Purchase" to record your shopping
## Troubleshooting
@ -85,8 +85,8 @@ This will:
**Complete Backend**: FastAPI with SQLAlchemy and SQLite
**Modern Frontend**: React with TypeScript and Tailwind CSS
**Database Models**: Groceries, Shops, Shopping Events
**Database Models**: Products, Shops, Shopping Events
**API Documentation**: Automatic Swagger docs
**Beautiful UI**: Responsive design with modern components
Happy grocery tracking! 🛒
Happy product tracking! 🛒

View File

@ -1,12 +1,12 @@
# Grocery Tracker
# Product Tracker
A web application for tracking grocery prices and shopping events. Built with FastAPI (Python) backend and React (TypeScript) frontend.
A web application for tracking product prices and shopping events. Built with FastAPI (Python) backend and React (TypeScript) frontend.
## Features
- **Grocery Management**: Add, edit, and track grocery 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
- **Shopping Events**: Record purchases with multiple groceries and amounts
- **Shopping Events**: Record purchases with multiple products and amounts
- **Price Tracking**: Monitor price changes over time
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
@ -42,9 +42,9 @@ A web application for tracking grocery prices and shopping events. Built with Fa
### Core Entities
#### Groceries (`groceries` table)
#### Products (`products` table)
- `id`: Integer, Primary key, Auto-increment
- `name`: String, Grocery name (indexed, required)
- `name`: String, Product name (indexed, required)
- `category`: String, Food category (required)
- `organic`: Boolean, Organic flag (default: false)
- `weight`: Float, Weight/volume (optional)
@ -59,6 +59,7 @@ A web application for tracking grocery prices and shopping events. Built with Fa
- `city`: String, Location city (required)
- `address`: String, Full address (optional)
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
#### Shopping Events (`shopping_events` table)
- `id`: Integer, Primary key, Auto-increment
@ -67,14 +68,15 @@ A web application for tracking grocery prices and shopping events. Built with Fa
- `total_amount`: Float, Total cost of shopping event (optional, auto-calculated)
- `notes`: String, Optional notes about the purchase
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
### Association Table
#### Shopping Event Groceries (`shopping_event_groceries` table)
Many-to-many relationship between shopping events and groceries with additional data:
#### Shopping Event Products (`shopping_event_products` table)
Many-to-many relationship between shopping events and products with additional data:
- `id`: Integer, Primary key, Auto-increment
- `shopping_event_id`: Integer, Foreign key to shopping_events (required)
- `grocery_id`: Integer, Foreign key to groceries (required)
- `product_id`: Integer, Foreign key to products (required)
- `amount`: Float, Quantity purchased in this event (required, > 0)
- `price`: Float, Price at time of purchase (required, ≥ 0)
@ -82,10 +84,10 @@ Many-to-many relationship between shopping events and groceries with additional
```
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
│ Shops │ │ Shopping Event Groceries │ │ Groceries │
│ Shops │ │ Shopping Event Products │ │ Products │
│ │ │ (Association Table) │ │ │
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
│ • name │ 1:N │ • grocery_id │ N:M │ • name │
│ • name │ 1:N │ • product_id │ N:M │ • name │
│ • city │ │ • amount │ │ • category │
│ • address │ │ • price │ │ • organic │
│ • created_at │ │ │ │ • weight │
@ -108,7 +110,7 @@ Many-to-many relationship between shopping events and groceries with additional
### Key Features
- **Price History**: Each grocery 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)
- **Auto-calculation**: Total amount can be automatically calculated from individual items
- **Free Items**: Supports items with price 0 (samples, promotions, etc.)
@ -144,7 +146,7 @@ Many-to-many relationship between shopping events and groceries with additional
4. **Setup database:**
```bash
# Create PostgreSQL database
createdb grocery_tracker
createdb product_tracker
# Copy environment variables
cp env.example .env
@ -187,12 +189,12 @@ Many-to-many relationship between shopping events and groceries with additional
## API Endpoints
### 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
- `GET /products/` - List all products
- `POST /products/` - Create new product
- `GET /products/{id}` - Get specific product
- `PUT /products/{id}` - Update product
- `DELETE /products/{id}` - Delete product
### Shops
- `GET /shops/` - List all shops
@ -210,8 +212,8 @@ Many-to-many relationship between shopping events and groceries with additional
## Usage
1. **Add Shops**: Start by adding shops where you buy groceries
2. **Add Groceries**: Create grocery items with prices and categories
1. **Add Shops**: Start by adding shops where you buy products
2. **Add Products**: Create product items with prices and categories
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
4. **Track Prices**: Monitor how prices change over time
5. **View Statistics**: Analyze spending patterns by category and shop
@ -250,8 +252,8 @@ services:
db:
image: postgres:15
environment:
POSTGRES_DB: grocery_tracker
POSTGRES_USER: grocery_user
POSTGRES_DB: product_tracker
POSTGRES_USER: product_user
POSTGRES_PASSWORD: your_password
volumes:
- postgres_data:/var/lib/postgresql/data
@ -265,7 +267,7 @@ services:
depends_on:
- db
environment:
DATABASE_URL: postgresql://grocery_user:your_password@db:5432/grocery_tracker
DATABASE_URL: postgresql://product_user:your_password@db:5432/product_tracker
frontend:
build: ./frontend

28
backend/Dockerfile Normal file
View File

@ -0,0 +1,28 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements first for better caching
COPY requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY . .
# Create a non-root user
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
USER appuser
# Expose port
EXPOSE 8000
# Command to run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -11,7 +11,7 @@ DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL:
# Default to SQLite for development if no PostgreSQL URL is provided
DATABASE_URL = "sqlite:///./grocery_tracker.db"
DATABASE_URL = "sqlite:///./product_tracker.db"
print("🔄 Using SQLite database for development")
else:
print(f"🐘 Using PostgreSQL database")

View File

@ -1,9 +1,9 @@
# Database Configuration
# Option 1: PostgreSQL (for production)
# DATABASE_URL=postgresql://username:password@localhost:5432/grocery_tracker
# DATABASE_URL=postgresql://username:password@localhost:5432/product_tracker
# Option 2: SQLite (for development - default if DATABASE_URL is not set)
# DATABASE_URL=sqlite:///./grocery_tracker.db
# DATABASE_URL=sqlite:///./product_tracker.db
# Authentication (optional for basic setup)
SECRET_KEY=your-secret-key-here

View File

@ -10,8 +10,8 @@ from database import engine, get_db
models.Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Grocery Tracker API",
description="API for tracking grocery prices and shopping events",
title="Product Tracker API",
description="API for tracking product prices and shopping events",
version="1.0.0"
)
@ -25,33 +25,69 @@ app.add_middleware(
)
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
"""Build a shopping event response with groceries from the association table"""
# Get groceries with their event-specific data
grocery_data = db.execute(
"""Build a shopping event response with products from the association table"""
# Get products with their event-specific data including grocery and brand information
product_data = db.execute(
text("""
SELECT g.id, g.name, g.category, g.organic, g.weight, g.weight_unit,
seg.amount, seg.price
FROM groceries g
JOIN shopping_event_groceries seg ON g.id = seg.grocery_id
WHERE seg.shopping_event_id = :event_id
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
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.created_at as category_created_at, gc.updated_at as category_updated_at,
b.id as brand_id, b.name as brand_name,
b.created_at as brand_created_at, b.updated_at as brand_updated_at
FROM products p
JOIN shopping_event_products sep ON p.id = sep.product_id
JOIN groceries g ON p.grocery_id = g.id
JOIN grocery_categories gc ON g.category_id = gc.id
LEFT JOIN brands b ON p.brand_id = b.id
WHERE sep.shopping_event_id = :event_id
"""),
{"event_id": event.id}
).fetchall()
# Convert to GroceryWithEventData objects
groceries_with_data = [
schemas.GroceryWithEventData(
id=row.id,
name=row.name,
category=row.category,
organic=row.organic,
weight=row.weight,
weight_unit=row.weight_unit,
amount=row.amount,
price=row.price
# Convert to ProductWithEventData objects
products_with_data = []
for row in product_data:
category = schemas.GroceryCategory(
id=row.category_id,
name=row.category_name,
created_at=row.category_created_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
if row.brand_id is not None:
brand = schemas.Brand(
id=row.brand_id,
name=row.brand_name,
created_at=row.brand_created_at,
updated_at=row.brand_updated_at
)
products_with_data.append(
schemas.ProductWithEventData(
id=row.id,
name=row.name,
grocery=grocery,
brand=brand,
organic=row.organic,
weight=row.weight,
weight_unit=row.weight_unit,
amount=row.amount,
price=row.price
)
)
for row in grocery_data
]
return schemas.ShoppingEventResponse(
id=event.id,
@ -61,58 +97,82 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
notes=event.notes,
created_at=event.created_at,
shop=event.shop,
groceries=groceries_with_data
products=products_with_data
)
# Root endpoint
@app.get("/")
def read_root():
return {"message": "Grocery Tracker API", "version": "1.0.0"}
return {"message": "Product Tracker API", "version": "1.0.0"}
# Grocery endpoints
@app.post("/groceries/", response_model=schemas.Grocery)
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
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()
# Product endpoints
@app.post("/products/", response_model=schemas.Product)
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
# Validate grocery exists
grocery = db.query(models.Grocery).filter(models.Grocery.id == product.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 brand exists if brand_id is provided
if product.brand_id is not None:
brand = db.query(models.Brand).filter(models.Brand.id == product.brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
db_product = models.Product(**product.dict())
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
@app.get("/products/", response_model=List[schemas.Product])
def read_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
products = db.query(models.Product).offset(skip).limit(limit).all()
return products
@app.get("/products/{product_id}", response_model=schemas.Product)
def read_product(product_id: int, db: Session = Depends(get_db)):
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")
return product
@app.put("/products/{product_id}", response_model=schemas.Product)
def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
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")
update_data = product_update.dict(exclude_unset=True)
# Validate grocery exists if grocery_id is being updated
if 'grocery_id' in update_data:
grocery = db.query(models.Grocery).filter(models.Grocery.id == update_data['grocery_id']).first()
if grocery is None:
raise HTTPException(status_code=404, detail="Grocery not found")
# Validate brand exists if brand_id is being updated
if 'brand_id' in update_data and update_data['brand_id'] is not None:
brand = db.query(models.Brand).filter(models.Brand.id == update_data['brand_id']).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
for field, value in update_data.items():
setattr(grocery, field, value)
setattr(product, field, value)
db.commit()
db.refresh(grocery)
return grocery
db.refresh(product)
return product
@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")
@app.delete("/products/{product_id}")
def delete_product(product_id: int, db: Session = Depends(get_db)):
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")
db.delete(grocery)
db.delete(product)
db.commit()
return {"message": "Grocery deleted successfully"}
return {"message": "Product deleted successfully"}
# Shop endpoints
@app.post("/shops/", response_model=schemas.Shop)
@ -141,7 +201,7 @@ def update_shop(shop_id: int, shop_update: schemas.ShopUpdate, db: Session = Dep
if shop is None:
raise HTTPException(status_code=404, detail="Shop not found")
update_data = shop_update.dict()
update_data = shop_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(shop, field, value)
@ -159,6 +219,177 @@ def delete_shop(shop_id: int, db: Session = Depends(get_db)):
db.commit()
return {"message": "Shop deleted successfully"}
# Brand endpoints
@app.post("/brands/", response_model=schemas.Brand)
def create_brand(brand: schemas.BrandCreate, db: Session = Depends(get_db)):
db_brand = models.Brand(**brand.dict())
db.add(db_brand)
db.commit()
db.refresh(db_brand)
return db_brand
@app.get("/brands/", response_model=List[schemas.Brand])
def read_brands(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
brands = db.query(models.Brand).offset(skip).limit(limit).all()
return brands
@app.get("/brands/{brand_id}", response_model=schemas.Brand)
def read_brand(brand_id: int, db: Session = Depends(get_db)):
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
return brand
@app.put("/brands/{brand_id}", response_model=schemas.Brand)
def update_brand(brand_id: int, brand_update: schemas.BrandUpdate, db: Session = Depends(get_db)):
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
update_data = brand_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(brand, field, value)
db.commit()
db.refresh(brand)
return brand
@app.delete("/brands/{brand_id}")
def delete_brand(brand_id: int, db: Session = Depends(get_db)):
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
# Check if any products reference this brand
products_with_brand = db.query(models.Product).filter(models.Product.brand_id == brand_id).first()
if products_with_brand:
raise HTTPException(
status_code=400,
detail="Cannot delete brand: products are still associated with this brand"
)
db.delete(brand)
db.commit()
return {"message": "Brand deleted successfully"}
# Grocery Category endpoints
@app.post("/grocery-categories/", response_model=schemas.GroceryCategory)
def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)):
db_category = models.GroceryCategory(**category.dict())
db.add(db_category)
db.commit()
db.refresh(db_category)
return db_category
@app.get("/grocery-categories/", response_model=List[schemas.GroceryCategory])
def read_grocery_categories(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
categories = db.query(models.GroceryCategory).offset(skip).limit(limit).all()
return categories
@app.get("/grocery-categories/{category_id}", response_model=schemas.GroceryCategory)
def read_grocery_category(category_id: int, db: Session = Depends(get_db)):
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first()
if category is None:
raise HTTPException(status_code=404, detail="Grocery category not found")
return category
@app.put("/grocery-categories/{category_id}", response_model=schemas.GroceryCategory)
def update_grocery_category(category_id: int, category_update: schemas.GroceryCategoryUpdate, db: Session = Depends(get_db)):
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first()
if category is None:
raise HTTPException(status_code=404, detail="Grocery category not found")
update_data = category_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(category, field, value)
db.commit()
db.refresh(category)
return category
@app.delete("/grocery-categories/{category_id}")
def delete_grocery_category(category_id: int, db: Session = Depends(get_db)):
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first()
if category is None:
raise HTTPException(status_code=404, detail="Grocery category not found")
# Check if any groceries reference this category
groceries_with_category = db.query(models.Grocery).filter(models.Grocery.category_id == category_id).first()
if groceries_with_category:
raise HTTPException(
status_code=400,
detail="Cannot delete category: groceries are still associated with this category"
)
db.delete(category)
db.commit()
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
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
@ -178,19 +409,19 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
db.commit()
db.refresh(db_event)
# Add groceries to the event
for grocery_item in event.groceries:
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
if grocery is None:
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
# Add products to the event
for product_item in event.products:
product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
if product is None:
raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
# Insert into association table
db.execute(
models.shopping_event_groceries.insert().values(
models.shopping_event_products.insert().values(
shopping_event_id=db_event.id,
grocery_id=grocery_item.grocery_id,
amount=grocery_item.amount,
price=grocery_item.price
product_id=product_item.product_id,
amount=product_item.amount,
price=product_item.price
)
)
@ -228,26 +459,26 @@ def update_shopping_event(event_id: int, event_update: schemas.ShoppingEventCrea
event.total_amount = event_update.total_amount
event.notes = event_update.notes
# Remove existing grocery associations
# Remove existing product associations
db.execute(
models.shopping_event_groceries.delete().where(
models.shopping_event_groceries.c.shopping_event_id == event_id
models.shopping_event_products.delete().where(
models.shopping_event_products.c.shopping_event_id == event_id
)
)
# Add new grocery associations
for grocery_item in event_update.groceries:
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
if grocery is None:
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
# Add new product associations
for product_item in event_update.products:
product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
if product is None:
raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
# Insert into association table
db.execute(
models.shopping_event_groceries.insert().values(
models.shopping_event_products.insert().values(
shopping_event_id=event_id,
grocery_id=grocery_item.grocery_id,
amount=grocery_item.amount,
price=grocery_item.price
product_id=product_item.product_id,
amount=product_item.amount,
price=product_item.price
)
)
@ -261,10 +492,10 @@ def delete_shopping_event(event_id: int, db: Session = Depends(get_db)):
if event is None:
raise HTTPException(status_code=404, detail="Shopping event not found")
# Delete grocery associations first
# Delete product associations first
db.execute(
models.shopping_event_groceries.delete().where(
models.shopping_event_groceries.c.shopping_event_id == event_id
models.shopping_event_products.delete().where(
models.shopping_event_products.c.shopping_event_id == event_id
)
)

View File

@ -6,23 +6,59 @@ from datetime import datetime
Base = declarative_base()
# Association table for many-to-many relationship between shopping events and groceries
shopping_event_groceries = Table(
'shopping_event_groceries',
# Association table for many-to-many relationship between shopping events and products
shopping_event_products = Table(
'shopping_event_products',
Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
Column('grocery_id', Integer, ForeignKey('groceries.id'), nullable=False),
Column('amount', Float, nullable=False), # Amount of this grocery bought in this event
Column('price', Float, nullable=False) # Price of this grocery at the time of this shopping event
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('amount', Float, nullable=False), # Amount of this product bought in this event
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
)
class Brand(Base):
__tablename__ = "brands"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
products = relationship("Product", back_populates="brand")
class GroceryCategory(Base):
__tablename__ = "grocery_categories"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
groceries = relationship("Grocery", back_populates="category")
class Grocery(Base):
__tablename__ = "groceries"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
category = Column(String, nullable=False)
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):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
grocery_id = Column(Integer, ForeignKey("groceries.id"), nullable=False)
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True)
organic = Column(Boolean, default=False)
weight = Column(Float, nullable=True) # in grams or kg
weight_unit = Column(String, default="piece") # "g", "kg", "ml", "l", "piece"
@ -30,7 +66,9 @@ class Grocery(Base):
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_groceries, back_populates="groceries")
grocery = relationship("Grocery", back_populates="products")
brand = relationship("Brand", back_populates="products")
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
class Shop(Base):
__tablename__ = "shops"
@ -40,6 +78,7 @@ class Shop(Base):
city = Column(String, nullable=False)
address = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
shopping_events = relationship("ShoppingEvent", back_populates="shop")
@ -53,7 +92,8 @@ class ShoppingEvent(Base):
total_amount = Column(Float, nullable=True) # Total cost of the shopping event
notes = Column(String, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
shop = relationship("Shop", back_populates="shopping_events")
groceries = relationship("Grocery", secondary=shopping_event_groceries, back_populates="shopping_events")
products = relationship("Product", secondary=shopping_event_products, back_populates="shopping_events")

View File

@ -10,4 +10,5 @@ python-multipart>=0.0.6
python-dotenv>=1.0.0
pytest>=7.4.3
pytest-asyncio>=0.21.1
httpx>=0.25.2
httpx>=0.25.2
requests>=2.31.0

View File

@ -22,7 +22,7 @@ def main():
backend_dir = Path(__file__).parent
os.chdir(backend_dir)
print("🍃 Starting Grocery Tracker Backend Development Server")
print("🍃 Starting Product Tracker Backend Development Server")
print("=" * 50)
# Check if virtual environment exists

View File

@ -2,28 +2,89 @@ from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
# Base schemas
# Brand schemas
class BrandBase(BaseModel):
name: str
class BrandCreate(BrandBase):
pass
class BrandUpdate(BaseModel):
name: Optional[str] = None
class Brand(BrandBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Grocery Category schemas
class GroceryCategoryBase(BaseModel):
name: str
class GroceryCategoryCreate(GroceryCategoryBase):
pass
class GroceryCategoryUpdate(BaseModel):
name: Optional[str] = None
class GroceryCategory(GroceryCategoryBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Grocery schemas
class GroceryBase(BaseModel):
name: str
category: str
organic: bool = False
weight: Optional[float] = None
weight_unit: str = "g"
category_id: int
class GroceryCreate(GroceryBase):
pass
class GroceryUpdate(BaseModel):
name: Optional[str] = None
category: Optional[str] = None
organic: Optional[bool] = None
weight: Optional[float] = None
weight_unit: 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):
name: str
grocery_id: int
brand_id: Optional[int] = None
organic: bool = False
weight: Optional[float] = None
weight_unit: str = "g"
class ProductCreate(ProductBase):
pass
class ProductUpdate(BaseModel):
name: Optional[str] = None
grocery_id: Optional[int] = None
brand_id: Optional[int] = None
organic: Optional[bool] = None
weight: Optional[float] = None
weight_unit: Optional[str] = None
class Product(ProductBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
grocery: Grocery
brand: Optional[Brand] = None
class Config:
from_attributes = True
@ -45,20 +106,22 @@ class ShopUpdate(BaseModel):
class Shop(ShopBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
class Config:
from_attributes = True
# Shopping Event schemas
class GroceryInEvent(BaseModel):
grocery_id: int
class ProductInEvent(BaseModel):
product_id: int
amount: float = Field(..., gt=0)
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
class GroceryWithEventData(BaseModel):
class ProductWithEventData(BaseModel):
id: int
name: str
category: str
grocery: Grocery
brand: Optional[Brand] = None
organic: bool
weight: Optional[float] = None
weight_unit: str
@ -75,20 +138,21 @@ class ShoppingEventBase(BaseModel):
notes: Optional[str] = None
class ShoppingEventCreate(ShoppingEventBase):
groceries: List[GroceryInEvent] = []
products: List[ProductInEvent] = []
class ShoppingEventUpdate(BaseModel):
shop_id: Optional[int] = None
date: Optional[datetime] = None
total_amount: Optional[float] = Field(None, ge=0)
notes: Optional[str] = None
groceries: Optional[List[GroceryInEvent]] = None
products: Optional[List[ProductInEvent]] = None
class ShoppingEventResponse(ShoppingEventBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
shop: Shop
groceries: List[GroceryWithEventData] = []
products: List[ProductWithEventData] = []
class Config:
from_attributes = True

594
database_schema.drawio Normal file
View File

@ -0,0 +1,594 @@
<mxfile host="65bd71144e">
<diagram name="Product Tracker Database Schema" id="database-schema">
<mxGraphModel dx="1848" dy="501" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
<root>
<mxCell id="0"/>
<mxCell id="1" parent="0"/>
<mxCell id="shop-event-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="43" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="300" y="470" as="sourcePoint"/>
<mxPoint x="350" y="420" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="event-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="43" target="99" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="620" y="310" as="sourcePoint"/>
<mxPoint x="720" y="270" as="targetPoint"/>
</mxGeometry>
</mxCell>
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="280" y="150" as="sourcePoint"/>
<mxPoint x="720" y="290" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
</mxCell>
<mxCell id="2" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="420" y="470" width="180" height="300" as="geometry"/>
</mxCell>
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="4" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="5" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="6" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="7" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="8" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="128" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="129" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="128" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="130" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="128" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="9" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="10" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="11" value="grocery_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="12" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="13" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="14" value="organic: BOOLEAN" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="19" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="18" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="20" value="weight: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="18" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="36" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="210" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="37" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="36" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="38" value="weight_unit: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="36" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="21" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="240" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="22" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="21" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="23" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="21" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="15" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="270" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="16" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="15" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="15" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="39" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_events&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="420" y="110" width="180" height="240" as="geometry"/>
</mxCell>
<mxCell id="40" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="39" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="41" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="42" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="43" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="44" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="45" value="shop_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="46" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="47" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="48" value="date: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="49" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="50" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="51" value="total_amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="52" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="53" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="52" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="54" value="notes: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="52" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="58" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="59" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="58" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="60" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="58" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="111" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="210" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="112" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="111" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="113" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="111" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="70" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shops&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="80" y="80" width="180" height="210" as="geometry"/>
</mxCell>
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="72" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="73" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="74" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="75" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="76" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="77" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="78" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="79" value="city: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="80" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="81" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="82" value="address: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="89" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="90" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="89" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="91" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="89" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="92" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;strokeColor=#b85450;" parent="70" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="93" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="92" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="94" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="92" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="95" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_event_products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="810" y="200" width="240" height="180" as="geometry"/>
</mxCell>
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
<mxGeometry y="30" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="97" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="99" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="60" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="100" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="102" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="90" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="103" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="105" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="120" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="106" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="108" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
<mxGeometry y="150" width="240" height="30" as="geometry"/>
</mxCell>
<mxCell id="109" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="108" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1">
<mxGeometry x="30" width="210" height="30" as="geometry">
<mxRectangle width="210" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="114" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="90" y="480" width="180" height="150" as="geometry"/>
</mxCell>
<mxCell id="115" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="114" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="116" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="115" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="117" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="115" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="118" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="114" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="119" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="118" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="120" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="118" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="121" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="114" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="122" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="121" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="123" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="121" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="124" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="114" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="125" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="124" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="126" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="124" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="127" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="115" target="128" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="610" y="525" as="sourcePoint"/>
<mxPoint x="820" y="315" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="131" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;groceries&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="90" y="700" width="180" height="180" as="geometry"/>
</mxCell>
<mxCell id="132" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="131" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="133" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="134" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="135" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="136" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="137" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="140" value="category_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="138" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="141" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="141" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="143" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="141" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="144" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="145" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="144" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="146" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="144" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="147" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="132" target="9" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="280" y="535" as="sourcePoint"/>
<mxPoint x="430" y="585" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="148" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;grocerie_categories&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
<mxGeometry x="-210" y="715" width="180" height="150" as="geometry"/>
</mxCell>
<mxCell id="149" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="148">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="150" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="152" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="154" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="156" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="155">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="155">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="158" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="158">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="160" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="158">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="149" target="138">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="270" y="785" as="sourcePoint"/>
<mxPoint x="80" y="835" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

73
docker-compose.yml Normal file
View File

@ -0,0 +1,73 @@
version: '3.8'
services:
# PostgreSQL Database
db:
image: postgres:15-alpine
container_name: groceries_db
environment:
POSTGRES_DB: product_tracker
POSTGRES_USER: product_user
POSTGRES_PASSWORD: ${DB_PASSWORD:-secure_password_123}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U product_user -d product_tracker"]
interval: 10s
timeout: 5s
retries: 5
restart: unless-stopped
# FastAPI Backend
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: groceries_backend
environment:
DATABASE_URL: postgresql://product_user:${DB_PASSWORD:-secure_password_123}@db:5432/product_tracker
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-this-in-production}
ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: 30
DEBUG: ${DEBUG:-False}
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
volumes:
- ./backend:/app
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
interval: 30s
timeout: 10s
retries: 3
# React Frontend
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: groceries_frontend
ports:
- "80:80"
depends_on:
- backend
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:80"]
interval: 30s
timeout: 10s
retries: 3
volumes:
postgres_data:
driver: local
networks:
default:
name: groceries_network

11
docker.env.example Normal file
View File

@ -0,0 +1,11 @@
# Database Configuration
DB_PASSWORD=secure_password_123
# Backend Configuration
SECRET_KEY=your-super-secret-key-change-this-in-production-make-it-very-long-and-random
DEBUG=False
# Optional: Override default ports if needed
# FRONTEND_PORT=80
# BACKEND_PORT=8000
# DB_PORT=5432

31
frontend/Dockerfile Normal file
View File

@ -0,0 +1,31 @@
# Build stage
FROM node:18-alpine as build
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built app from build stage
COPY --from=build /app/build /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Expose port
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

26
frontend/nginx.conf Normal file
View File

@ -0,0 +1,26 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Handle React Router
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Handle static files
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@ -1,11 +1,11 @@
{
"name": "grocery-tracker-frontend",
"name": "product-tracker-frontend",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "grocery-tracker-frontend",
"name": "product-tracker-frontend",
"version": "0.1.0",
"dependencies": {
"@types/node": "^20.10.5",

View File

@ -1,5 +1,5 @@
{
"name": "grocery-tracker-frontend",
"name": "product-tracker-frontend",
"version": "0.1.0",
"private": true,
"dependencies": {

View File

@ -7,9 +7,9 @@
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Track grocery prices and shopping events"
content="Track product prices and shopping events"
/>
<title>Grocery Tracker</title>
<title>Product Tracker</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>

View File

@ -1,6 +1,6 @@
#!/bin/bash
echo "🎯 Setting up Grocery Tracker Frontend"
echo "🎯 Setting up Product Tracker Frontend"
echo "======================================"
# Check if Node.js is installed

View File

@ -1,72 +1,122 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
import GroceryList from './components/GroceryList';
import ShopList from './components/ShopList';
import ShoppingEventForm from './components/ShoppingEventForm';
import ShoppingEventList from './components/ShoppingEventList';
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
import Dashboard from './components/Dashboard';
import ShopList from './components/ShopList';
import ProductList from './components/ProductList';
import ShoppingEventList from './components/ShoppingEventList';
import ShoppingEventForm from './components/ShoppingEventForm';
import BrandList from './components/BrandList';
import GroceryList from './components/GroceryList';
import GroceryCategoryList from './components/GroceryCategoryList';
function Navigation() {
const location = useLocation();
const isActive = (path: string) => {
return location.pathname === path;
};
return (
<nav className="bg-blue-600 shadow-lg">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex space-x-8">
<Link
to="/"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
isActive('/')
? 'text-white border-b-2 border-white'
: 'text-blue-100 hover:text-white'
}`}
>
Dashboard
</Link>
<Link
to="/shopping-events"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
isActive('/shopping-events')
? 'text-white border-b-2 border-white'
: 'text-blue-100 hover:text-white'
}`}
>
Shopping Events
</Link>
<Link
to="/shops"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
isActive('/shops')
? 'text-white border-b-2 border-white'
: 'text-blue-100 hover:text-white'
}`}
>
Shops
</Link>
<Link
to="/products"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
isActive('/products')
? 'text-white border-b-2 border-white'
: 'text-blue-100 hover:text-white'
}`}
>
Products
</Link>
<Link
to="/brands"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
isActive('/brands')
? 'text-white border-b-2 border-white'
: 'text-blue-100 hover:text-white'
}`}
>
Brands
</Link>
<Link
to="/groceries"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
isActive('/groceries')
? 'text-white border-b-2 border-white'
: 'text-blue-100 hover:text-white'
}`}
>
Groceries
</Link>
<Link
to="/categories"
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
isActive('/categories')
? 'text-white border-b-2 border-white'
: 'text-blue-100 hover:text-white'
}`}
>
Categories
</Link>
</div>
</div>
</div>
</nav>
);
}
function App() {
return (
<Router>
<div className="min-h-screen bg-gray-50">
{/* Navigation */}
<nav className="bg-white shadow-lg">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex">
<div className="flex-shrink-0 flex items-center">
<h1 className="text-xl font-bold text-gray-800">
🛒 Grocery Tracker
</h1>
</div>
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
<Link
to="/"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Dashboard
</Link>
<Link
to="/groceries"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Groceries
</Link>
<Link
to="/shops"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Shops
</Link>
<Link
to="/shopping-events"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
>
Shopping Events
</Link>
<Link
to="/add-purchase"
className="bg-blue-500 hover:bg-blue-700 text-white inline-flex items-center px-3 py-2 text-sm font-medium rounded-md"
>
Add New Event
</Link>
</div>
</div>
</div>
<Navigation />
<main className="py-10">
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/shopping-events" element={<ShoppingEventList />} />
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
<Route path="/shops" element={<ShopList />} />
<Route path="/products" element={<ProductList />} />
<Route path="/brands" element={<BrandList />} />
<Route path="/groceries" element={<GroceryList />} />
<Route path="/categories" element={<GroceryCategoryList />} />
</Routes>
</div>
</nav>
{/* Main Content */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/groceries" element={<GroceryList />} />
<Route path="/shops" element={<ShopList />} />
<Route path="/shopping-events" element={<ShoppingEventList />} />
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
<Route path="/add-purchase" element={<ShoppingEventForm />} />
</Routes>
</main>
</div>
</Router>

View File

@ -0,0 +1,149 @@
import React, { useState, useEffect } from 'react';
import { brandApi } from '../services/api';
import { Brand } from '../types';
interface AddBrandModalProps {
isOpen: boolean;
onClose: () => void;
onBrandAdded: () => void;
editBrand?: Brand | null;
}
interface BrandFormData {
name: string;
}
const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandAdded, editBrand }) => {
const [formData, setFormData] = useState<BrandFormData>({
name: ''
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!editBrand;
// Initialize form data when editing
useEffect(() => {
if (editBrand) {
setFormData({
name: editBrand.name
});
} else {
setFormData({
name: ''
});
}
setError('');
}, [editBrand, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim()) {
setError('Please enter a brand name');
return;
}
try {
setLoading(true);
setError('');
const brandData = {
name: formData.name.trim()
};
if (isEditMode && editBrand) {
await brandApi.update(editBrand.id, brandData);
} else {
await brandApi.create(brandData);
}
// Reset form
setFormData({
name: ''
});
onBrandAdded();
onClose();
} catch (err) {
setError(`Failed to ${isEditMode ? 'update' : 'add'} brand. Please try again.`);
console.error(`Error ${isEditMode ? 'updating' : 'adding'} brand:`, err);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{isEditMode ? 'Edit Brand' : 'Add New Brand'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Brand Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Coca-Cola, Nestlé, Apple"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (isEditMode ? 'Updating...' : 'Adding...') : (isEditMode ? 'Update Brand' : 'Add Brand')}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddBrandModal;

View File

@ -0,0 +1,111 @@
import React, { useState, useEffect } from 'react';
import { GroceryCategory, GroceryCategoryCreate } from '../types';
import { groceryCategoryApi } from '../services/api';
interface AddGroceryCategoryModalProps {
category?: GroceryCategory | null;
onClose: () => void;
}
const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ category, onClose }) => {
const [formData, setFormData] = useState<GroceryCategoryCreate>({
name: ''
});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const isEditMode = Boolean(category);
useEffect(() => {
if (category) {
setFormData({
name: category.name
});
}
}, [category]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
if (isEditMode && category) {
await groceryCategoryApi.update(category.id, formData);
setMessage('Category updated successfully!');
} else {
await groceryCategoryApi.create(formData);
setMessage('Category created successfully!');
}
setTimeout(() => {
onClose();
}, 1500);
} catch (error) {
console.error('Error saving category:', error);
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
} finally {
setLoading(false);
}
};
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<h3 className="text-lg font-medium text-gray-900 mb-4">
{isEditMode ? 'Edit Category' : 'Add New Category'}
</h3>
{message && (
<div className={`mb-4 p-4 rounded-md ${
message.includes('Error')
? 'bg-red-50 text-red-700'
: 'bg-green-50 text-green-700'
}`}>
{message}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Category Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter category name"
required
/>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading
? (isEditMode ? 'Updating...' : 'Creating...')
: (isEditMode ? 'Update Category' : 'Create Category')
}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddGroceryCategoryModal;

View File

@ -1,228 +1,121 @@
import React, { useState, useEffect } from 'react';
import { groceryApi } from '../services/api';
import { Grocery } from '../types';
import { Grocery, GroceryCreate, GroceryCategory } from '../types';
import { groceryApi, groceryCategoryApi } from '../services/api';
interface AddGroceryModalProps {
isOpen: boolean;
grocery?: Grocery | null;
onClose: () => void;
onGroceryAdded: () => void;
editGrocery?: Grocery | null;
}
interface GroceryFormData {
name: string;
category: string;
organic: boolean;
weight?: number;
weight_unit: string;
}
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => {
const [formData, setFormData] = useState<GroceryFormData>({
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) => {
const [formData, setFormData] = useState<GroceryCreate>({
name: '',
category: '',
organic: false,
weight: undefined,
weight_unit: 'piece'
category_id: 0
});
const [categories, setCategories] = useState<GroceryCategory[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const categories = [
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry', 'Frozen',
'Bakery', 'Beverages', 'Snacks', 'Health & Beauty', 'Household', 'Other'
];
const isEditMode = Boolean(grocery);
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
// Populate form when editing
useEffect(() => {
if (editGrocery) {
fetchCategories();
if (grocery) {
setFormData({
name: editGrocery.name,
category: editGrocery.category,
organic: editGrocery.organic,
weight: editGrocery.weight,
weight_unit: editGrocery.weight_unit
});
} else {
// Reset form for adding new grocery
setFormData({
name: '',
category: '',
organic: false,
weight: undefined,
weight_unit: 'piece'
name: grocery.name,
category_id: grocery.category_id
});
}
setError('');
}, [editGrocery, isOpen]);
}, [grocery]);
const fetchCategories = async () => {
try {
const response = await groceryCategoryApi.getAll();
setCategories(response.data);
} catch (error) {
console.error('Error fetching categories:', error);
setMessage('Error loading categories. Please try again.');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.category.trim()) {
setError('Please fill in all required fields with valid values');
return;
}
setLoading(true);
setMessage('');
try {
setLoading(true);
setError('');
const groceryData = {
...formData,
weight: formData.weight || undefined
};
if (editGrocery) {
// Update existing grocery
await groceryApi.update(editGrocery.id, groceryData);
if (isEditMode && grocery) {
await groceryApi.update(grocery.id, formData);
setMessage('Grocery updated successfully!');
} else {
// Create new grocery
await groceryApi.create(groceryData);
await groceryApi.create(formData);
setMessage('Grocery created successfully!');
}
// Reset form
setFormData({
name: '',
category: '',
organic: false,
weight: undefined,
weight_unit: 'piece'
});
onGroceryAdded();
onClose();
} catch (err) {
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`);
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err);
setTimeout(() => {
onClose();
}, 1500);
} catch (error) {
console.error('Error saving grocery:', error);
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
: type === 'number' ? (value === '' ? undefined : Number(value))
: value
}));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{editGrocery ? 'Edit Grocery' : 'Add New Grocery'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<h3 className="text-lg font-medium text-gray-900 mb-4">
{isEditMode ? 'Edit Grocery' : 'Add New Grocery'}
</h3>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
{message && (
<div className={`mb-4 p-4 rounded-md ${
message.includes('Error')
? 'bg-red-50 text-red-700'
: 'bg-green-50 text-green-700'
}`}>
{message}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name *
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Grocery Name
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter grocery name"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Organic Bananas"
/>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
Category *
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Category
</label>
<select
id="category"
name="category"
value={formData.category}
onChange={handleChange}
value={formData.category_id}
onChange={(e) => setFormData({...formData, category_id: parseInt(e.target.value)})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a category</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
<option value={0}>Select a category</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="weight" className="block text-sm font-medium text-gray-700">
Weight/Quantity
</label>
<input
type="number"
id="weight"
name="weight"
value={formData.weight || ''}
onChange={handleChange}
min="0"
step="0.01"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="1"
/>
</div>
<div>
<label htmlFor="weight_unit" className="block text-sm font-medium text-gray-700">
Unit
</label>
<select
id="weight_unit"
name="weight_unit"
value={formData.weight_unit}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
{weightUnits.map(unit => (
<option key={unit} value={unit}>{unit}</option>
))}
</select>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="organic"
name="organic"
checked={formData.organic}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="organic" className="ml-2 block text-sm text-gray-900">
Organic
</label>
</div>
<div className="flex justify-end space-x-3 pt-4">
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
@ -232,12 +125,12 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
</button>
<button
type="submit"
disabled={loading}
disabled={loading || formData.category_id === 0}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading
? (editGrocery ? 'Updating...' : 'Adding...')
: (editGrocery ? 'Update Grocery' : 'Add Grocery')
? (isEditMode ? 'Updating...' : 'Creating...')
: (isEditMode ? 'Update Grocery' : 'Create Grocery')
}
</button>
</div>

View File

@ -0,0 +1,304 @@
import React, { useState, useEffect } from 'react';
import { productApi, brandApi, groceryApi } from '../services/api';
import { Product, Brand, Grocery } from '../types';
interface AddProductModalProps {
isOpen: boolean;
onClose: () => void;
onProductAdded: () => void;
editProduct?: Product | null;
}
interface ProductFormData {
name: string;
grocery_id?: number;
brand_id?: number;
organic: boolean;
weight?: number;
weight_unit: string;
}
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
const [formData, setFormData] = useState<ProductFormData>({
name: '',
grocery_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
});
const [brands, setBrands] = useState<Brand[]>([]);
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
// Fetch brands and groceries when modal opens
useEffect(() => {
if (isOpen) {
fetchBrands();
fetchGroceries();
}
}, [isOpen]);
const fetchBrands = async () => {
try {
const response = await brandApi.getAll();
setBrands(response.data);
} catch (err) {
console.error('Error fetching brands:', err);
}
};
const fetchGroceries = async () => {
try {
const response = await groceryApi.getAll();
setGroceries(response.data);
} catch (err) {
console.error('Error fetching groceries:', err);
}
};
// Populate form when editing
useEffect(() => {
if (editProduct) {
setFormData({
name: editProduct.name,
grocery_id: editProduct.grocery_id,
brand_id: editProduct.brand_id,
organic: editProduct.organic,
weight: editProduct.weight,
weight_unit: editProduct.weight_unit
});
} else {
// Reset form for adding new product
setFormData({
name: '',
grocery_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
});
}
setError('');
}, [editProduct, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.grocery_id) {
setError('Please fill in all required fields with valid values');
return;
}
try {
setLoading(true);
setError('');
const productData = {
name: formData.name.trim(),
grocery_id: formData.grocery_id!,
brand_id: formData.brand_id || undefined,
organic: formData.organic,
weight: formData.weight || undefined,
weight_unit: formData.weight_unit
};
if (editProduct) {
// Update existing product
await productApi.update(editProduct.id, productData);
} else {
// Create new product
await productApi.create(productData);
}
// Reset form
setFormData({
name: '',
grocery_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
});
onProductAdded();
onClose();
} catch (err) {
setError(`Failed to ${editProduct ? 'update' : 'add'} product. Please try again.`);
console.error(`Error ${editProduct ? 'updating' : 'adding'} product:`, err);
} finally {
setLoading(false);
}
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { name, value, type } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
: type === 'number' ? (value === '' ? undefined : Number(value))
: name === 'brand_id' || name === 'grocery_id' ? (value === '' ? undefined : Number(value))
: value
}));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{editProduct ? 'Edit Product' : 'Add New Product'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Whole Foods Organic Milk"
/>
</div>
<div>
<label htmlFor="grocery_id" className="block text-sm font-medium text-gray-700">
Grocery Type *
</label>
<select
id="grocery_id"
name="grocery_id"
value={formData.grocery_id || ''}
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a grocery type</option>
{groceries.map(grocery => (
<option key={grocery.id} value={grocery.id}>
{grocery.name} ({grocery.category.name})
</option>
))}
</select>
</div>
<div>
<label htmlFor="brand_id" className="block text-sm font-medium text-gray-700">
Brand (Optional)
</label>
<select
id="brand_id"
name="brand_id"
value={formData.brand_id || ''}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
<option value="">Select a brand (optional)</option>
{brands.map(brand => (
<option key={brand.id} value={brand.id}>{brand.name}</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label htmlFor="weight" className="block text-sm font-medium text-gray-700">
Weight/Quantity
</label>
<input
type="number"
id="weight"
name="weight"
value={formData.weight || ''}
onChange={handleChange}
min="0"
step="0.01"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="1"
/>
</div>
<div>
<label htmlFor="weight_unit" className="block text-sm font-medium text-gray-700">
Unit
</label>
<select
id="weight_unit"
name="weight_unit"
value={formData.weight_unit}
onChange={handleChange}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
>
{weightUnits.map(unit => (
<option key={unit} value={unit}>{unit}</option>
))}
</select>
</div>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="organic"
name="organic"
checked={formData.organic}
onChange={handleChange}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<label htmlFor="organic" className="ml-2 block text-sm text-gray-900">
Organic
</label>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading
? (editProduct ? 'Updating...' : 'Adding...')
: (editProduct ? 'Update Product' : 'Add Product')
}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddProductModal;

View File

@ -0,0 +1,184 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Brand } from '../types';
import { brandApi } from '../services/api';
import AddBrandModal from './AddBrandModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const BrandList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingBrand, setEditingBrand] = useState<Brand | null>(null);
const [deletingBrand, setDeletingBrand] = useState<Brand | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
useEffect(() => {
fetchBrands();
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const fetchBrands = async () => {
try {
setLoading(true);
const response = await brandApi.getAll();
setBrands(response.data);
} catch (err) {
setError('Failed to fetch brands');
console.error('Error fetching brands:', err);
} finally {
setLoading(false);
}
};
const handleBrandAdded = () => {
fetchBrands(); // Refresh the brands list
};
const handleEditBrand = (brand: Brand) => {
setEditingBrand(brand);
setIsModalOpen(true);
};
const handleDeleteBrand = (brand: Brand) => {
setDeletingBrand(brand);
};
const confirmDelete = async () => {
if (!deletingBrand) return;
try {
setDeleteLoading(true);
await brandApi.delete(deletingBrand.id);
setDeletingBrand(null);
fetchBrands(); // Refresh the brands list
} catch (err: any) {
console.error('Error deleting brand:', err);
// Handle specific error message from backend
if (err.response?.status === 400) {
setError('Cannot delete brand: products are still associated with this brand');
} else {
setError('Failed to delete brand. Please try again.');
}
} finally {
setDeleteLoading(false);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingBrand(null);
};
const handleCloseDeleteModal = () => {
setDeletingBrand(null);
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Brands</h1>
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add New Brand
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{brands.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a1.994 1.994 0 01-1.414.586H7m0-18v18m0-18h.01" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No brands</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
{brands.map((brand) => (
<div key={brand.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-medium text-gray-900">{brand.name}</h3>
<div className="flex space-x-2">
<button
onClick={() => handleEditBrand(brand)}
className="text-indigo-600 hover:text-indigo-900 text-sm"
>
Edit
</button>
<button
onClick={() => handleDeleteBrand(brand)}
className="text-red-600 hover:text-red-900 text-sm"
>
Delete
</button>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center text-sm text-gray-600">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Added {new Date(brand.created_at).toLocaleDateString()}
</div>
{brand.updated_at && (
<div className="flex items-center text-sm text-gray-600">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Updated {new Date(brand.updated_at).toLocaleDateString()}
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<AddBrandModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onBrandAdded={handleBrandAdded}
editBrand={editingBrand}
/>
<ConfirmDeleteModal
isOpen={!!deletingBrand}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Brand"
message={`Are you sure you want to delete "${deletingBrand?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);
};
export default BrandList;

View File

@ -32,7 +32,7 @@ const Dashboard: React.FC = () => {
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Welcome to your grocery tracker!</p>
<p className="text-gray-600">Welcome to your product tracker!</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
@ -102,7 +102,7 @@ const Dashboard: React.FC = () => {
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => navigate('/add-purchase')}
onClick={() => navigate('/shopping-events/new')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="p-2 bg-blue-100 rounded-md mr-3">
@ -117,7 +117,7 @@ const Dashboard: React.FC = () => {
</button>
<button
onClick={() => navigate('/groceries?add=true')}
onClick={() => navigate('/products?add=true')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="p-2 bg-green-100 rounded-md mr-3">
@ -126,8 +126,8 @@ const Dashboard: React.FC = () => {
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Add Grocery</p>
<p className="text-sm text-gray-600">Add a new grocery item</p>
<p className="font-medium text-gray-900">Add Product</p>
<p className="text-sm text-gray-600">Add a new product item</p>
</div>
</button>
@ -181,9 +181,9 @@ const Dashboard: React.FC = () => {
<p className="text-sm text-gray-600 mt-1">
{new Date(event.date).toLocaleDateString()}
</p>
{event.groceries.length > 0 && (
{event.products.length > 0 && (
<p className="text-sm text-gray-500 mt-1">
{event.groceries.length} item{event.groceries.length !== 1 ? 's' : ''}
{event.products.length} item{event.products.length !== 1 ? 's' : ''}
</p>
)}
</div>

View File

@ -0,0 +1,156 @@
import React, { useState, useEffect } from 'react';
import { GroceryCategory } from '../types';
import { groceryCategoryApi } from '../services/api';
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
const GroceryCategoryList: React.FC = () => {
const [categories, setCategories] = useState<GroceryCategory[]>([]);
const [loading, setLoading] = useState(true);
const [message, setMessage] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingCategory, setEditingCategory] = useState<GroceryCategory | null>(null);
useEffect(() => {
fetchCategories();
}, []);
const fetchCategories = async () => {
try {
setLoading(true);
const response = await groceryCategoryApi.getAll();
setCategories(response.data);
} catch (error) {
console.error('Error fetching categories:', error);
setMessage('Error loading categories. Please try again.');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (window.confirm('Are you sure you want to delete this category?')) {
try {
await groceryCategoryApi.delete(id);
setMessage('Category deleted successfully!');
fetchCategories();
setTimeout(() => setMessage(''), 3000);
} catch (error: any) {
console.error('Error deleting category:', error);
if (error.response?.status === 400) {
setMessage('Cannot delete category: groceries are still associated with this category.');
} else {
setMessage('Error deleting category. Please try again.');
}
setTimeout(() => setMessage(''), 5000);
}
}
};
const handleEdit = (category: GroceryCategory) => {
setEditingCategory(category);
setIsModalOpen(true);
};
const handleModalClose = () => {
setIsModalOpen(false);
setEditingCategory(null);
fetchCategories();
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Grocery Categories
</h3>
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add Category
</button>
</div>
{message && (
<div className={`mb-4 p-4 rounded-md ${
message.includes('Error') || message.includes('Cannot')
? 'bg-red-50 text-red-700'
: 'bg-green-50 text-green-700'
}`}>
{message}
</div>
)}
{categories.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">No categories found. Add your first category!</p>
</div>
) : (
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{categories.map((category) => (
<tr key={category.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{category.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(category.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(category)}
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</button>
<button
onClick={() => handleDelete(category.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
{isModalOpen && (
<AddGroceryCategoryModal
category={editingCategory}
onClose={handleModalClose}
/>
)}
</div>
);
};
export default GroceryCategoryList;

View File

@ -1,80 +1,60 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Grocery } from '../types';
import { groceryApi } from '../services/api';
import AddGroceryModal from './AddGroceryModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const GroceryList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
useEffect(() => {
fetchGroceries();
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
}, []);
const fetchGroceries = async () => {
try {
setLoading(true);
const response = await groceryApi.getAll();
setGroceries(response.data);
} catch (err) {
setError('Failed to fetch groceries');
console.error('Error fetching groceries:', err);
} catch (error) {
console.error('Error fetching groceries:', error);
setMessage('Error loading groceries. Please try again.');
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (window.confirm('Are you sure you want to delete this grocery?')) {
try {
await groceryApi.delete(id);
setMessage('Grocery deleted successfully!');
fetchGroceries();
setTimeout(() => setMessage(''), 3000);
} catch (error: any) {
console.error('Error deleting grocery:', error);
if (error.response?.status === 400) {
setMessage('Cannot delete grocery: products are still associated with this grocery.');
} else {
setMessage('Error deleting grocery. Please try again.');
}
setTimeout(() => setMessage(''), 5000);
}
}
};
const handleEdit = (grocery: Grocery) => {
setEditingGrocery(grocery);
setIsModalOpen(true);
};
const handleDelete = (grocery: Grocery) => {
setDeletingGrocery(grocery);
};
const confirmDelete = async () => {
if (!deletingGrocery) return;
try {
setDeleteLoading(true);
await groceryApi.delete(deletingGrocery.id);
setDeletingGrocery(null);
fetchGroceries(); // Refresh the list
} catch (err) {
console.error('Error deleting grocery:', err);
setError('Failed to delete grocery. Please try again.');
} finally {
setDeleteLoading(false);
}
};
const handleGroceryAdded = () => {
fetchGroceries(); // Refresh the list
};
const handleCloseModal = () => {
const handleModalClose = () => {
setIsModalOpen(false);
setEditingGrocery(null);
};
const handleCloseDeleteModal = () => {
setDeletingGrocery(null);
fetchGroceries();
};
if (loading) {
@ -86,115 +66,95 @@ const GroceryList: React.FC = () => {
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
<button
onClick={() => {
setEditingGrocery(null);
setIsModalOpen(true);
}}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add New Grocery
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{groceries.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
<div className="max-w-4xl mx-auto">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Groceries
</h3>
<button
onClick={() => setIsModalOpen(true)}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add Grocery
</button>
</div>
) : (
<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">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Weight
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organic
</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">
{groceries.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">
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
{grocery.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
grocery.organic
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{grocery.organic ? 'Organic' : 'Conventional'}
</span>
</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>
)}
{message && (
<div className={`mb-4 p-4 rounded-md ${
message.includes('Error') || message.includes('Cannot')
? 'bg-red-50 text-red-700'
: 'bg-green-50 text-green-700'
}`}>
{message}
</div>
)}
{groceries.length === 0 ? (
<div className="text-center py-8">
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
</div>
) : (
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
<table className="min-w-full divide-y divide-gray-300">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Created
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{groceries.map((grocery) => (
<tr key={grocery.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{grocery.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{grocery.category.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(grocery.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
onClick={() => handleEdit(grocery)}
className="text-indigo-600 hover:text-indigo-900 mr-4"
>
Edit
</button>
<button
onClick={() => handleDelete(grocery.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
<AddGroceryModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onGroceryAdded={handleGroceryAdded}
editGrocery={editingGrocery}
/>
<ConfirmDeleteModal
isOpen={!!deletingGrocery}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Grocery"
message={`Are you sure you want to delete "${deletingGrocery?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
{isModalOpen && (
<AddGroceryModal
grocery={editingGrocery}
onClose={handleModalClose}
/>
)}
</div>
);
};

View File

@ -0,0 +1,209 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Product } from '../types';
import { productApi } from '../services/api';
import AddProductModal from './AddProductModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const ProductList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
useEffect(() => {
fetchProducts();
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const fetchProducts = async () => {
try {
setLoading(true);
const response = await productApi.getAll();
setProducts(response.data);
} catch (err) {
setError('Failed to fetch products');
console.error('Error fetching products:', err);
} finally {
setLoading(false);
}
};
const handleEdit = (product: Product) => {
setEditingProduct(product);
setIsModalOpen(true);
};
const handleDelete = (product: Product) => {
setDeletingProduct(product);
};
const confirmDelete = async () => {
if (!deletingProduct) return;
try {
setDeleteLoading(true);
await productApi.delete(deletingProduct.id);
setDeletingProduct(null);
fetchProducts(); // Refresh the list
} catch (err) {
console.error('Error deleting product:', err);
setError('Failed to delete product. Please try again.');
} finally {
setDeleteLoading(false);
}
};
const handleProductAdded = () => {
fetchProducts(); // Refresh the list
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingProduct(null);
};
const handleCloseDeleteModal = () => {
setDeletingProduct(null);
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Products</h1>
<button
onClick={() => {
setEditingProduct(null);
setIsModalOpen(true);
}}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add New Product
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{products.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 products</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first product item.</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">
Name
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Grocery
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Brand
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Weight
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organic
</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">
{products.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{product.name} {product.organic ? '🌱' : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">{product.grocery.name}</div>
<div className="text-xs text-gray-500">{product.grocery.category.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.brand ? product.brand.name : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
product.organic
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{product.organic ? 'Organic' : 'Conventional'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEdit(product)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDelete(product)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<AddProductModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onProductAdded={handleProductAdded}
editProduct={editingProduct}
/>
<ConfirmDeleteModal
isOpen={!!deletingProduct}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Product"
message={`Are you sure you want to delete "${deletingProduct?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);
};
export default ProductList;

View File

@ -158,6 +158,15 @@ const ShopList: React.FC = () => {
</svg>
Added {new Date(shop.created_at).toLocaleDateString()}
</div>
{shop.updated_at && (
<div className="flex items-center text-sm text-gray-600">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Updated {new Date(shop.updated_at).toLocaleDateString()}
</div>
)}
</div>
</div>
))}

View File

@ -1,13 +1,13 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types';
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
import { shopApi, productApi, shoppingEventApi } from '../services/api';
const ShoppingEventForm: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [shops, setShops] = useState<Shop[]>([]);
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [loading, setLoading] = useState(false);
const [loadingEvent, setLoadingEvent] = useState(false);
const [message, setMessage] = useState('');
@ -19,61 +19,24 @@ const ShoppingEventForm: React.FC = () => {
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
products: []
});
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
grocery_id: 0,
const [selectedProducts, setSelectedProducts] = useState<ProductInEvent[]>([]);
const [newProductItem, setNewProductItem] = useState<ProductInEvent>({
product_id: 0,
amount: 1,
price: 0
});
const [autoCalculate, setAutoCalculate] = useState<boolean>(true);
useEffect(() => {
fetchShops();
fetchGroceries();
if (isEditMode && id) {
fetchShoppingEvent(parseInt(id));
}
}, [id, isEditMode]);
// Calculate total amount from selected groceries
const calculateTotal = (groceries: GroceryInEvent[]): number => {
const total = groceries.reduce((total, item) => total + (item.amount * item.price), 0);
// Calculate total amount from selected products
const calculateTotal = (products: ProductInEvent[]): number => {
const total = products.reduce((total, item) => total + (item.amount * item.price), 0);
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
};
// Update total amount whenever selectedGroceries changes
useEffect(() => {
if (autoCalculate) {
const calculatedTotal = calculateTotal(selectedGroceries);
setFormData(prev => ({
...prev,
total_amount: calculatedTotal > 0 ? calculatedTotal : undefined
}));
}
}, [selectedGroceries, autoCalculate]);
const fetchShops = async () => {
try {
const response = await shopApi.getAll();
setShops(response.data);
} catch (error) {
console.error('Error fetching shops:', error);
}
};
const fetchGroceries = async () => {
try {
const response = await groceryApi.getAll();
setGroceries(response.data);
} catch (error) {
console.error('Error fetching groceries:', error);
}
};
const fetchShoppingEvent = async (eventId: number) => {
const fetchShoppingEvent = useCallback(async (eventId: number) => {
try {
setLoadingEvent(true);
const response = await shoppingEventApi.getById(eventId);
@ -86,15 +49,15 @@ const ShoppingEventForm: React.FC = () => {
formattedDate = event.date.split('T')[0];
}
// Map groceries to the format we need
const mappedGroceries = event.groceries.map(g => ({
grocery_id: g.id,
amount: g.amount,
price: g.price
// Map products to the format we need
const mappedProducts = event.products.map(p => ({
product_id: p.id,
amount: p.amount,
price: p.price
}));
// Calculate the sum of all groceries
const calculatedTotal = calculateTotal(mappedGroceries);
// Calculate the sum of all products
const calculatedTotal = calculateTotal(mappedProducts);
// Check if existing total matches calculated total (with small tolerance for floating point)
const existingTotal = event.total_amount || 0;
@ -105,10 +68,10 @@ const ShoppingEventForm: React.FC = () => {
date: formattedDate,
total_amount: event.total_amount,
notes: event.notes || '',
groceries: []
products: []
});
setSelectedGroceries(mappedGroceries);
setSelectedProducts(mappedProducts);
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
} catch (error) {
console.error('Error fetching shopping event:', error);
@ -116,29 +79,66 @@ const ShoppingEventForm: React.FC = () => {
} finally {
setLoadingEvent(false);
}
};
}, []);
const addGroceryToEvent = () => {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price >= 0) {
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
setNewGroceryItem({ grocery_id: 0, amount: 1, price: 0 });
useEffect(() => {
fetchShops();
fetchProducts();
if (isEditMode && id) {
fetchShoppingEvent(parseInt(id));
}
}, [id, isEditMode, fetchShoppingEvent]);
// Update total amount whenever selectedProducts changes
useEffect(() => {
if (autoCalculate) {
const calculatedTotal = calculateTotal(selectedProducts);
setFormData(prev => ({
...prev,
total_amount: calculatedTotal > 0 ? calculatedTotal : undefined
}));
}
}, [selectedProducts, autoCalculate]);
const fetchShops = async () => {
try {
const response = await shopApi.getAll();
setShops(response.data);
} catch (error) {
console.error('Error fetching shops:', error);
}
};
const removeGroceryFromEvent = (index: number) => {
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
const fetchProducts = async () => {
try {
const response = await productApi.getAll();
setProducts(response.data);
} catch (error) {
console.error('Error fetching products:', error);
}
};
const editGroceryFromEvent = (index: number) => {
const groceryToEdit = selectedGroceries[index];
// Load the grocery data into the input fields
setNewGroceryItem({
grocery_id: groceryToEdit.grocery_id,
amount: groceryToEdit.amount,
price: groceryToEdit.price
const addProductToEvent = () => {
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
setNewProductItem({ product_id: 0, amount: 1, price: 0 });
}
};
const removeProductFromEvent = (index: number) => {
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
};
const editProductFromEvent = (index: number) => {
const productToEdit = selectedProducts[index];
// Load the product data into the input fields
setNewProductItem({
product_id: productToEdit.product_id,
amount: productToEdit.amount,
price: productToEdit.price
});
// Remove the item from the selected list
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
@ -149,7 +149,7 @@ const ShoppingEventForm: React.FC = () => {
try {
const eventData = {
...formData,
groceries: selectedGroceries
products: selectedProducts
};
if (isEditMode) {
@ -173,9 +173,9 @@ const ShoppingEventForm: React.FC = () => {
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
products: []
});
setSelectedGroceries([]);
setSelectedProducts([]);
}
} catch (error) {
console.error('Full error object:', error);
@ -185,12 +185,13 @@ const ShoppingEventForm: React.FC = () => {
}
};
const getGroceryName = (id: number) => {
const grocery = groceries.find(g => g.id === id);
if (!grocery) return 'Unknown';
const getProductName = (id: number) => {
const product = products.find(p => p.id === id);
if (!product) return 'Unknown';
const weightInfo = grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit;
return `${grocery.name} ${weightInfo}`;
const weightInfo = product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit;
const organicEmoji = product.organic ? ' 🌱' : '';
return `${product.name}${organicEmoji} ${weightInfo}`;
};
if (loadingEvent) {
@ -264,25 +265,25 @@ const ShoppingEventForm: React.FC = () => {
/>
</div>
{/* Add Groceries Section */}
{/* Add Products Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add Groceries
Add Products
</label>
<div className="flex space-x-2 mb-4">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Grocery
Product
</label>
<select
value={newGroceryItem.grocery_id}
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
value={newProductItem.product_id}
onChange={(e) => setNewProductItem({...newProductItem, product_id: parseInt(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>Select a grocery</option>
{groceries.map(grocery => (
<option key={grocery.id} value={grocery.id}>
{grocery.name} ({grocery.category}) {grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit}
<option value={0}>Select a product</option>
{products.map(product => (
<option key={product.id} value={product.id}>
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category.name}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
</option>
))}
</select>
@ -296,8 +297,8 @@ const ShoppingEventForm: React.FC = () => {
step="1"
min="1"
placeholder="1"
value={newGroceryItem.amount}
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
value={newProductItem.amount}
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -310,15 +311,15 @@ const ShoppingEventForm: React.FC = () => {
step="0.01"
min="0"
placeholder="0.00"
value={newGroceryItem.price}
onChange={(e) => setNewGroceryItem({...newGroceryItem, price: parseFloat(e.target.value)})}
value={newProductItem.price}
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<button
type="button"
onClick={addGroceryToEvent}
onClick={addProductToEvent}
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
>
Add
@ -326,15 +327,15 @@ const ShoppingEventForm: React.FC = () => {
</div>
</div>
{/* Selected Groceries List */}
{selectedGroceries.length > 0 && (
{/* Selected Products List */}
{selectedProducts.length > 0 && (
<div className="bg-gray-50 rounded-md p-4">
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
{selectedGroceries.map((item, index) => (
{selectedProducts.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
<div className="flex-1">
<div className="text-sm text-gray-900">
{getGroceryName(item.grocery_id)}
{getProductName(item.product_id)}
</div>
<div className="text-xs text-gray-600">
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
@ -343,14 +344,14 @@ const ShoppingEventForm: React.FC = () => {
<div className="flex space-x-2">
<button
type="button"
onClick={() => editGroceryFromEvent(index)}
onClick={() => editProductFromEvent(index)}
className="text-blue-500 hover:text-blue-700"
>
Edit
</button>
<button
type="button"
onClick={() => removeGroceryFromEvent(index)}
onClick={() => removeProductFromEvent(index)}
className="text-red-500 hover:text-red-700"
>
Remove
@ -430,7 +431,7 @@ const ShoppingEventForm: React.FC = () => {
)}
<button
type="submit"
disabled={loading || formData.shop_id === 0 || selectedGroceries.length === 0}
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${
isEditMode
? 'bg-blue-600 hover:bg-blue-700'

View File

@ -66,7 +66,7 @@ const ShoppingEventList: React.FC = () => {
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
<button
onClick={() => navigate('/add-purchase')}
onClick={() => navigate('/shopping-events/new')}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add New Event
@ -109,15 +109,17 @@ const ShoppingEventList: React.FC = () => {
</div>
</div>
{event.groceries.length > 0 && (
{event.products.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{event.groceries.map((grocery) => (
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
<div className="text-sm text-gray-900">{grocery.name}</div>
{event.products.map((product) => (
<div key={product.id} className="bg-gray-50 rounded px-3 py-2">
<div className="text-sm text-gray-900">
{product.name} {product.organic ? '🌱' : ''}
</div>
<div className="text-xs text-gray-600">
{grocery.amount} × ${grocery.price.toFixed(2)} = ${(grocery.amount * grocery.price).toFixed(2)}
{product.amount} × ${product.price.toFixed(2)} = ${(product.amount * product.price).toFixed(2)}
</div>
</div>
))}
@ -133,9 +135,17 @@ const ShoppingEventList: React.FC = () => {
)}
<div className="flex justify-between items-center text-sm">
<span className="text-gray-500">
Event #{event.id} {new Date(event.created_at).toLocaleDateString()}
</span>
<div className="text-gray-500">
<div>Event #{event.id} Created {new Date(event.created_at).toLocaleDateString()}</div>
{event.updated_at && (
<div className="flex items-center mt-1">
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Updated {new Date(event.updated_at).toLocaleDateString()}
</div>
)}
</div>
<div className="flex space-x-2">
<button
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}

View File

@ -1,23 +1,34 @@
import axios from 'axios';
import { Grocery, GroceryCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate } from '../types';
const BASE_URL = 'http://localhost:8000';
const API_BASE_URL = 'http://localhost:8000';
const api = axios.create({
baseURL: BASE_URL,
headers: {
'Content-Type': 'application/json',
},
});
const api = {
get: <T>(url: string): Promise<{ data: T }> =>
fetch(`${API_BASE_URL}${url}`).then(res => res.json()).then(data => ({ data })),
post: <T>(url: string, body: any): Promise<{ data: T }> =>
fetch(`${API_BASE_URL}${url}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(res => res.json()).then(data => ({ data })),
put: <T>(url: string, body: any): Promise<{ data: T }> =>
fetch(`${API_BASE_URL}${url}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}).then(res => res.json()).then(data => ({ data })),
delete: (url: string): Promise<void> =>
fetch(`${API_BASE_URL}${url}`, { method: 'DELETE' }).then(() => {}),
};
// 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}`),
// Product API functions
export const productApi = {
getAll: () => api.get<Product[]>('/products/'),
getById: (id: number) => api.get<Product>(`/products/${id}`),
create: (product: ProductCreate) => api.post<Product>('/products/', product),
update: (id: number, product: Partial<ProductCreate>) =>
api.put<Product>(`/products/${id}`, product),
delete: (id: number) => api.delete(`/products/${id}`),
};
// Shop API functions
@ -25,22 +36,50 @@ export const shopApi = {
getAll: () => api.get<Shop[]>('/shops/'),
getById: (id: number) => api.get<Shop>(`/shops/${id}`),
create: (shop: ShopCreate) => api.post<Shop>('/shops/', shop),
update: (id: number, shop: Partial<ShopCreate>) =>
update: (id: number, shop: Partial<ShopCreate>) =>
api.put<Shop>(`/shops/${id}`, shop),
delete: (id: number) => api.delete(`/shops/${id}`),
};
// Brand API functions
export const brandApi = {
getAll: () => api.get<Brand[]>('/brands/'),
getById: (id: number) => api.get<Brand>(`/brands/${id}`),
create: (brand: BrandCreate) => api.post<Brand>('/brands/', brand),
update: (id: number, brand: Partial<BrandCreate>) =>
api.put<Brand>(`/brands/${id}`, brand),
delete: (id: number) => api.delete(`/brands/${id}`),
};
// Grocery Category API functions
export const groceryCategoryApi = {
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
getById: (id: number) => api.get<GroceryCategory>(`/grocery-categories/${id}`),
create: (category: GroceryCategoryCreate) => api.post<GroceryCategory>('/grocery-categories/', category),
update: (id: number, category: Partial<GroceryCategoryCreate>) => api.put<GroceryCategory>(`/grocery-categories/${id}`, category),
delete: (id: number) => api.delete(`/grocery-categories/${id}`),
};
// Shopping Event API functions
export const shoppingEventApi = {
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
create: (event: ShoppingEventCreate) =>
api.post<ShoppingEvent>('/shopping-events/', event),
update: (id: number, event: ShoppingEventCreate) =>
create: (event: ShoppingEventCreate) => api.post<ShoppingEvent>('/shopping-events/', event),
update: (id: number, event: ShoppingEventCreate) =>
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
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
export const statsApi = {
getCategories: () => api.get('/stats/categories'),

View File

@ -1,7 +1,46 @@
export interface Brand {
id: number;
name: string;
created_at: string;
updated_at?: string;
}
export interface BrandCreate {
name: string;
}
export interface GroceryCategory {
id: number;
name: string;
created_at: string;
updated_at?: string;
}
export interface GroceryCategoryCreate {
name: string;
}
export interface Grocery {
id: number;
name: string;
category: string;
category_id: number;
created_at: string;
updated_at?: string;
category: GroceryCategory;
}
export interface GroceryCreate {
name: string;
category_id: number;
}
export interface Product {
id: number;
name: string;
grocery_id: number;
grocery: Grocery;
brand_id?: number;
brand?: Brand;
organic: boolean;
weight?: number;
weight_unit: string;
@ -9,9 +48,10 @@ export interface Grocery {
updated_at?: string;
}
export interface GroceryCreate {
export interface ProductCreate {
name: string;
category: string;
grocery_id: number;
brand_id?: number;
organic: boolean;
weight?: number;
weight_unit: string;
@ -23,6 +63,7 @@ export interface Shop {
city: string;
address?: string;
created_at: string;
updated_at?: string;
}
export interface ShopCreate {
@ -31,16 +72,17 @@ export interface ShopCreate {
address?: string | null;
}
export interface GroceryInEvent {
grocery_id: number;
export interface ProductInEvent {
product_id: number;
amount: number;
price: number;
}
export interface GroceryWithEventData {
export interface ProductWithEventData {
id: number;
name: string;
category: string;
grocery: Grocery;
brand?: Brand;
organic: boolean;
weight?: number;
weight_unit: string;
@ -55,8 +97,9 @@ export interface ShoppingEvent {
total_amount?: number;
notes?: string;
created_at: string;
updated_at?: string;
shop: Shop;
groceries: GroceryWithEventData[];
products: ProductWithEventData[];
}
export interface ShoppingEventCreate {
@ -64,7 +107,7 @@ export interface ShoppingEventCreate {
date?: string;
total_amount?: number;
notes?: string;
groceries: GroceryInEvent[];
products: ProductInEvent[];
}
export interface CategoryStats {

View File

@ -1,15 +1,21 @@
{
"name": "grocery-tracker-frontend",
"version": "1.0.0",
"description": "React frontend for grocery price tracking application",
"private": true,
"name": "product-tracker-frontend",
"version": "0.1.0",
"description": "React frontend for product price tracking application",
"main": "index.js",
"scripts": {
"dev": "cd frontend && npm run dev",
"build": "cd frontend && npm run build",
"install:frontend": "cd frontend && npm install",
"setup": "npm run install:frontend"
"setup": "npm run install:frontend",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": ["grocery", "price-tracking", "shopping", "react", "fastapi", "python"],
"keywords": ["product", "price-tracking", "shopping", "react", "fastapi", "python"],
"author": "",
"license": "MIT"
"license": "ISC",
"dependencies": {},
"repository": {
"type": "git",
"url": ""
}
}