Compare commits
8 Commits
28db52dc2e
...
f88a931008
| Author | SHA1 | Date | |
|---|---|---|---|
| f88a931008 | |||
| 6118415f05 | |||
| 7e24d58a94 | |||
| 25c09dfecc | |||
| d27871160e | |||
| 1b984d18d9 | |||
| 71b36f7749 | |||
| 5cd9d65e00 |
370
DOCKER_DEPLOYMENT.md
Normal file
370
DOCKER_DEPLOYMENT.md
Normal 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!
|
||||
@ -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! 🛒
|
||||
50
README.md
50
README.md
@ -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
28
backend/Dockerfile
Normal 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"]
|
||||
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
379
backend/main.py
379
backend/main.py
@ -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(
|
||||
# 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,
|
||||
category=row.category,
|
||||
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)
|
||||
# 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")
|
||||
|
||||
# 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_grocery)
|
||||
return db_grocery
|
||||
db.refresh(db_product)
|
||||
return db_product
|
||||
|
||||
@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("/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("/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.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("/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()
|
||||
@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")
|
||||
|
||||
update_data = grocery_update.dict(exclude_unset=True)
|
||||
# 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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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")
|
||||
@ -11,3 +11,4 @@ python-dotenv>=1.0.0
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.21.1
|
||||
httpx>=0.25.2
|
||||
requests>=2.31.0
|
||||
@ -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
|
||||
|
||||
@ -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
594
database_schema.drawio
Normal 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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_events</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" 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
73
docker-compose.yml
Normal 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
11
docker.env.example
Normal 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
31
frontend/Dockerfile
Normal 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
26
frontend/nginx.conf
Normal 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";
|
||||
}
|
||||
}
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "grocery-tracker-frontend",
|
||||
"name": "product-tracker-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<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="/groceries" element={<GroceryList />} />
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
|
||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
||||
<Route path="/add-purchase" 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>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
149
frontend/src/components/AddBrandModal.tsx
Normal file
149
frontend/src/components/AddBrandModal.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { brandApi } from '../services/api';
|
||||
import { Brand } from '../types';
|
||||
|
||||
interface AddBrandModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onBrandAdded: () => void;
|
||||
editBrand?: Brand | null;
|
||||
}
|
||||
|
||||
interface BrandFormData {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandAdded, editBrand }) => {
|
||||
const [formData, setFormData] = useState<BrandFormData>({
|
||||
name: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const isEditMode = !!editBrand;
|
||||
|
||||
// Initialize form data when editing
|
||||
useEffect(() => {
|
||||
if (editBrand) {
|
||||
setFormData({
|
||||
name: editBrand.name
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: ''
|
||||
});
|
||||
}
|
||||
setError('');
|
||||
}, [editBrand, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim()) {
|
||||
setError('Please enter a brand name');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const brandData = {
|
||||
name: formData.name.trim()
|
||||
};
|
||||
|
||||
if (isEditMode && editBrand) {
|
||||
await brandApi.update(editBrand.id, brandData);
|
||||
} else {
|
||||
await brandApi.create(brandData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: ''
|
||||
});
|
||||
|
||||
onBrandAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(`Failed to ${isEditMode ? 'update' : 'add'} brand. Please try again.`);
|
||||
console.error(`Error ${isEditMode ? 'updating' : 'adding'} brand:`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{isEditMode ? 'Edit Brand' : 'Add New Brand'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Brand Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="e.g., Coca-Cola, Nestlé, Apple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (isEditMode ? 'Updating...' : 'Adding...') : (isEditMode ? 'Update Brand' : 'Add Brand')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddBrandModal;
|
||||
111
frontend/src/components/AddGroceryCategoryModal.tsx
Normal file
111
frontend/src/components/AddGroceryCategoryModal.tsx
Normal 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;
|
||||
@ -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();
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`);
|
||||
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err);
|
||||
}, 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 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{isEditMode ? '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>
|
||||
|
||||
{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>
|
||||
|
||||
304
frontend/src/components/AddProductModal.tsx
Normal file
304
frontend/src/components/AddProductModal.tsx
Normal 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;
|
||||
184
frontend/src/components/BrandList.tsx
Normal file
184
frontend/src/components/BrandList.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Brand } from '../types';
|
||||
import { brandApi } from '../services/api';
|
||||
import AddBrandModal from './AddBrandModal';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
|
||||
const BrandList: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingBrand, setEditingBrand] = useState<Brand | null>(null);
|
||||
const [deletingBrand, setDeletingBrand] = useState<Brand | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchBrands();
|
||||
|
||||
// Check if we should auto-open the modal
|
||||
if (searchParams.get('add') === 'true') {
|
||||
setIsModalOpen(true);
|
||||
// Remove the parameter from URL
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const fetchBrands = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await brandApi.getAll();
|
||||
setBrands(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch brands');
|
||||
console.error('Error fetching brands:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBrandAdded = () => {
|
||||
fetchBrands(); // Refresh the brands list
|
||||
};
|
||||
|
||||
const handleEditBrand = (brand: Brand) => {
|
||||
setEditingBrand(brand);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteBrand = (brand: Brand) => {
|
||||
setDeletingBrand(brand);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingBrand) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await brandApi.delete(deletingBrand.id);
|
||||
setDeletingBrand(null);
|
||||
fetchBrands(); // Refresh the brands list
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting brand:', err);
|
||||
// Handle specific error message from backend
|
||||
if (err.response?.status === 400) {
|
||||
setError('Cannot delete brand: products are still associated with this brand');
|
||||
} else {
|
||||
setError('Failed to delete brand. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingBrand(null);
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setDeletingBrand(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Brands</h1>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Brand
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{brands.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a1.994 1.994 0 01-1.414.586H7m0-18v18m0-18h.01" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No brands</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
{brands.map((brand) => (
|
||||
<div key={brand.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{brand.name}</h3>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => handleEditBrand(brand)}
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBrand(brand)}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Added {new Date(brand.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{brand.updated_at && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Updated {new Date(brand.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddBrandModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onBrandAdded={handleBrandAdded}
|
||||
editBrand={editingBrand}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={!!deletingBrand}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Brand"
|
||||
message={`Are you sure you want to delete "${deletingBrand?.name}"? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BrandList;
|
||||
@ -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>
|
||||
|
||||
156
frontend/src/components/GroceryCategoryList.tsx
Normal file
156
frontend/src/components/GroceryCategoryList.tsx
Normal 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;
|
||||
@ -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,37 +66,38 @@ 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>
|
||||
<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={() => {
|
||||
setEditingGrocery(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Grocery
|
||||
Add Grocery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="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') || message.includes('Cannot')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{groceries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<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">
|
||||
@ -126,12 +107,9 @@ const GroceryList: React.FC = () => {
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Weight
|
||||
Created
|
||||
</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">
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
@ -139,35 +117,24 @@ const GroceryList: React.FC = () => {
|
||||
<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 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">
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{grocery.category}
|
||||
</span>
|
||||
<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-900">
|
||||
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'}
|
||||
<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">
|
||||
<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">
|
||||
<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-3"
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(grocery)}
|
||||
onClick={() => handleDelete(grocery.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
@ -177,24 +144,17 @@ const GroceryList: React.FC = () => {
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<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}
|
||||
grocery={editingGrocery}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
209
frontend/src/components/ProductList.tsx
Normal file
209
frontend/src/components/ProductList.tsx
Normal 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;
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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`)}
|
||||
|
||||
@ -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
|
||||
@ -30,17 +41,45 @@ export const shopApi = {
|
||||
delete: (id: number) => api.delete(`/shops/${id}`),
|
||||
};
|
||||
|
||||
// Brand API functions
|
||||
export const brandApi = {
|
||||
getAll: () => api.get<Brand[]>('/brands/'),
|
||||
getById: (id: number) => api.get<Brand>(`/brands/${id}`),
|
||||
create: (brand: BrandCreate) => api.post<Brand>('/brands/', brand),
|
||||
update: (id: number, brand: Partial<BrandCreate>) =>
|
||||
api.put<Brand>(`/brands/${id}`, brand),
|
||||
delete: (id: number) => api.delete(`/brands/${id}`),
|
||||
};
|
||||
|
||||
// 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),
|
||||
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'),
|
||||
|
||||
@ -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 {
|
||||
|
||||
20
package.json
20
package.json
@ -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": ""
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user