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
|
# 🚀 Quick Start Guide
|
||||||
|
|
||||||
Get your Grocery Tracker up and running in minutes!
|
Get your Product Tracker up and running in minutes!
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
@ -62,8 +62,8 @@ This will:
|
|||||||
|
|
||||||
## First Steps
|
## First Steps
|
||||||
|
|
||||||
1. **Add a Shop**: Go to "Shops" and add your first grocery store
|
1. **Add a Shop**: Go to "Shops" and add your first store
|
||||||
2. **Add Groceries**: Go to "Groceries" and add some items
|
2. **Add Products**: Go to "Products" and add some items
|
||||||
3. **Record a Purchase**: Use "Add Purchase" to record your shopping
|
3. **Record a Purchase**: Use "Add Purchase" to record your shopping
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@ -85,8 +85,8 @@ This will:
|
|||||||
|
|
||||||
✅ **Complete Backend**: FastAPI with SQLAlchemy and SQLite
|
✅ **Complete Backend**: FastAPI with SQLAlchemy and SQLite
|
||||||
✅ **Modern Frontend**: React with TypeScript and Tailwind CSS
|
✅ **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
|
✅ **API Documentation**: Automatic Swagger docs
|
||||||
✅ **Beautiful UI**: Responsive design with modern components
|
✅ **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
|
## 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
|
- **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
|
- **Price Tracking**: Monitor price changes over time
|
||||||
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
|
- **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
|
### Core Entities
|
||||||
|
|
||||||
#### Groceries (`groceries` table)
|
#### Products (`products` table)
|
||||||
- `id`: Integer, Primary key, Auto-increment
|
- `id`: Integer, Primary key, Auto-increment
|
||||||
- `name`: String, Grocery name (indexed, required)
|
- `name`: String, Product name (indexed, required)
|
||||||
- `category`: String, Food category (required)
|
- `category`: String, Food category (required)
|
||||||
- `organic`: Boolean, Organic flag (default: false)
|
- `organic`: Boolean, Organic flag (default: false)
|
||||||
- `weight`: Float, Weight/volume (optional)
|
- `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)
|
- `city`: String, Location city (required)
|
||||||
- `address`: String, Full address (optional)
|
- `address`: String, Full address (optional)
|
||||||
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
||||||
|
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
||||||
|
|
||||||
#### Shopping Events (`shopping_events` table)
|
#### Shopping Events (`shopping_events` table)
|
||||||
- `id`: Integer, Primary key, Auto-increment
|
- `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)
|
- `total_amount`: Float, Total cost of shopping event (optional, auto-calculated)
|
||||||
- `notes`: String, Optional notes about the purchase
|
- `notes`: String, Optional notes about the purchase
|
||||||
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
||||||
|
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
||||||
|
|
||||||
### Association Table
|
### Association Table
|
||||||
|
|
||||||
#### Shopping Event Groceries (`shopping_event_groceries` table)
|
#### Shopping Event Products (`shopping_event_products` table)
|
||||||
Many-to-many relationship between shopping events and groceries with additional data:
|
Many-to-many relationship between shopping events and products with additional data:
|
||||||
- `id`: Integer, Primary key, Auto-increment
|
- `id`: Integer, Primary key, Auto-increment
|
||||||
- `shopping_event_id`: Integer, Foreign key to shopping_events (required)
|
- `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)
|
- `amount`: Float, Quantity purchased in this event (required, > 0)
|
||||||
- `price`: Float, Price at time of purchase (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) │ │ │
|
│ │ │ (Association Table) │ │ │
|
||||||
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
|
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
|
||||||
│ • name │ 1:N │ • grocery_id │ N:M │ • name │
|
│ • name │ 1:N │ • product_id │ N:M │ • name │
|
||||||
│ • city │ │ • amount │ │ • category │
|
│ • city │ │ • amount │ │ • category │
|
||||||
│ • address │ │ • price │ │ • organic │
|
│ • address │ │ • price │ │ • organic │
|
||||||
│ • created_at │ │ │ │ • weight │
|
│ • created_at │ │ │ │ • weight │
|
||||||
@ -108,7 +110,7 @@ Many-to-many relationship between shopping events and groceries with additional
|
|||||||
|
|
||||||
### Key Features
|
### 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)
|
- **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples)
|
||||||
- **Auto-calculation**: Total amount can be automatically calculated from individual items
|
- **Auto-calculation**: Total amount can be automatically calculated from individual items
|
||||||
- **Free Items**: Supports items with price 0 (samples, promotions, etc.)
|
- **Free Items**: Supports items with price 0 (samples, promotions, etc.)
|
||||||
@ -144,7 +146,7 @@ Many-to-many relationship between shopping events and groceries with additional
|
|||||||
4. **Setup database:**
|
4. **Setup database:**
|
||||||
```bash
|
```bash
|
||||||
# Create PostgreSQL database
|
# Create PostgreSQL database
|
||||||
createdb grocery_tracker
|
createdb product_tracker
|
||||||
|
|
||||||
# Copy environment variables
|
# Copy environment variables
|
||||||
cp env.example .env
|
cp env.example .env
|
||||||
@ -187,12 +189,12 @@ Many-to-many relationship between shopping events and groceries with additional
|
|||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
### Groceries
|
### Products
|
||||||
- `GET /groceries/` - List all groceries
|
- `GET /products/` - List all products
|
||||||
- `POST /groceries/` - Create new grocery
|
- `POST /products/` - Create new product
|
||||||
- `GET /groceries/{id}` - Get specific grocery
|
- `GET /products/{id}` - Get specific product
|
||||||
- `PUT /groceries/{id}` - Update grocery
|
- `PUT /products/{id}` - Update product
|
||||||
- `DELETE /groceries/{id}` - Delete grocery
|
- `DELETE /products/{id}` - Delete product
|
||||||
|
|
||||||
### Shops
|
### Shops
|
||||||
- `GET /shops/` - List all shops
|
- `GET /shops/` - List all shops
|
||||||
@ -210,8 +212,8 @@ Many-to-many relationship between shopping events and groceries with additional
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. **Add Shops**: Start by adding shops where you buy groceries
|
1. **Add Shops**: Start by adding shops where you buy products
|
||||||
2. **Add Groceries**: Create grocery items with prices and categories
|
2. **Add Products**: Create product items with prices and categories
|
||||||
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
|
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
|
||||||
4. **Track Prices**: Monitor how prices change over time
|
4. **Track Prices**: Monitor how prices change over time
|
||||||
5. **View Statistics**: Analyze spending patterns by category and shop
|
5. **View Statistics**: Analyze spending patterns by category and shop
|
||||||
@ -250,8 +252,8 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: postgres:15
|
image: postgres:15
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: grocery_tracker
|
POSTGRES_DB: product_tracker
|
||||||
POSTGRES_USER: grocery_user
|
POSTGRES_USER: product_user
|
||||||
POSTGRES_PASSWORD: your_password
|
POSTGRES_PASSWORD: your_password
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
@ -265,7 +267,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://grocery_user:your_password@db:5432/grocery_tracker
|
DATABASE_URL: postgresql://product_user:your_password@db:5432/product_tracker
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build: ./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:
|
if not DATABASE_URL:
|
||||||
# Default to SQLite for development if no PostgreSQL URL is provided
|
# 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")
|
print("🔄 Using SQLite database for development")
|
||||||
else:
|
else:
|
||||||
print(f"🐘 Using PostgreSQL database")
|
print(f"🐘 Using PostgreSQL database")
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
# Option 1: PostgreSQL (for production)
|
# 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)
|
# 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)
|
# Authentication (optional for basic setup)
|
||||||
SECRET_KEY=your-secret-key-here
|
SECRET_KEY=your-secret-key-here
|
||||||
|
|||||||
397
backend/main.py
397
backend/main.py
@ -10,8 +10,8 @@ from database import engine, get_db
|
|||||||
models.Base.metadata.create_all(bind=engine)
|
models.Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Grocery Tracker API",
|
title="Product Tracker API",
|
||||||
description="API for tracking grocery prices and shopping events",
|
description="API for tracking product prices and shopping events",
|
||||||
version="1.0.0"
|
version="1.0.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -25,33 +25,69 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
|
|
||||||
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
||||||
"""Build a shopping event response with groceries from the association table"""
|
"""Build a shopping event response with products from the association table"""
|
||||||
# Get groceries with their event-specific data
|
# Get products with their event-specific data including grocery and brand information
|
||||||
grocery_data = db.execute(
|
product_data = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT g.id, g.name, g.category, g.organic, g.weight, g.weight_unit,
|
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
|
||||||
seg.amount, seg.price
|
sep.amount, sep.price,
|
||||||
FROM groceries g
|
g.id as grocery_id, g.name as grocery_name,
|
||||||
JOIN shopping_event_groceries seg ON g.id = seg.grocery_id
|
g.created_at as grocery_created_at, g.updated_at as grocery_updated_at,
|
||||||
WHERE seg.shopping_event_id = :event_id
|
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}
|
{"event_id": event.id}
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
|
||||||
# Convert to GroceryWithEventData objects
|
# Convert to ProductWithEventData objects
|
||||||
groceries_with_data = [
|
products_with_data = []
|
||||||
schemas.GroceryWithEventData(
|
for row in product_data:
|
||||||
id=row.id,
|
category = schemas.GroceryCategory(
|
||||||
name=row.name,
|
id=row.category_id,
|
||||||
category=row.category,
|
name=row.category_name,
|
||||||
organic=row.organic,
|
created_at=row.category_created_at,
|
||||||
weight=row.weight,
|
updated_at=row.category_updated_at
|
||||||
weight_unit=row.weight_unit,
|
)
|
||||||
amount=row.amount,
|
|
||||||
price=row.price
|
grocery = schemas.Grocery(
|
||||||
|
id=row.grocery_id,
|
||||||
|
name=row.grocery_name,
|
||||||
|
category_id=row.category_id,
|
||||||
|
created_at=row.grocery_created_at,
|
||||||
|
updated_at=row.grocery_updated_at,
|
||||||
|
category=category
|
||||||
|
)
|
||||||
|
|
||||||
|
brand = None
|
||||||
|
if row.brand_id is not None:
|
||||||
|
brand = schemas.Brand(
|
||||||
|
id=row.brand_id,
|
||||||
|
name=row.brand_name,
|
||||||
|
created_at=row.brand_created_at,
|
||||||
|
updated_at=row.brand_updated_at
|
||||||
|
)
|
||||||
|
|
||||||
|
products_with_data.append(
|
||||||
|
schemas.ProductWithEventData(
|
||||||
|
id=row.id,
|
||||||
|
name=row.name,
|
||||||
|
grocery=grocery,
|
||||||
|
brand=brand,
|
||||||
|
organic=row.organic,
|
||||||
|
weight=row.weight,
|
||||||
|
weight_unit=row.weight_unit,
|
||||||
|
amount=row.amount,
|
||||||
|
price=row.price
|
||||||
|
)
|
||||||
)
|
)
|
||||||
for row in grocery_data
|
|
||||||
]
|
|
||||||
|
|
||||||
return schemas.ShoppingEventResponse(
|
return schemas.ShoppingEventResponse(
|
||||||
id=event.id,
|
id=event.id,
|
||||||
@ -61,58 +97,82 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
|||||||
notes=event.notes,
|
notes=event.notes,
|
||||||
created_at=event.created_at,
|
created_at=event.created_at,
|
||||||
shop=event.shop,
|
shop=event.shop,
|
||||||
groceries=groceries_with_data
|
products=products_with_data
|
||||||
)
|
)
|
||||||
|
|
||||||
# Root endpoint
|
# Root endpoint
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"message": "Grocery Tracker API", "version": "1.0.0"}
|
return {"message": "Product Tracker API", "version": "1.0.0"}
|
||||||
|
|
||||||
# Grocery endpoints
|
# Product endpoints
|
||||||
@app.post("/groceries/", response_model=schemas.Grocery)
|
@app.post("/products/", response_model=schemas.Product)
|
||||||
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
|
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
||||||
db_grocery = models.Grocery(**grocery.dict())
|
# Validate grocery exists
|
||||||
db.add(db_grocery)
|
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.commit()
|
||||||
db.refresh(db_grocery)
|
db.refresh(db_product)
|
||||||
return db_grocery
|
return db_product
|
||||||
|
|
||||||
@app.get("/groceries/", response_model=List[schemas.Grocery])
|
@app.get("/products/", response_model=List[schemas.Product])
|
||||||
def read_groceries(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
def read_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
groceries = db.query(models.Grocery).offset(skip).limit(limit).all()
|
products = db.query(models.Product).offset(skip).limit(limit).all()
|
||||||
return groceries
|
return products
|
||||||
|
|
||||||
@app.get("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
@app.get("/products/{product_id}", response_model=schemas.Product)
|
||||||
def read_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
def read_product(product_id: int, db: Session = Depends(get_db)):
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
if grocery is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
return grocery
|
return product
|
||||||
|
|
||||||
@app.put("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
@app.put("/products/{product_id}", response_model=schemas.Product)
|
||||||
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)):
|
def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
if grocery is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
|
update_data = product_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Validate grocery exists if grocery_id is being updated
|
||||||
|
if 'grocery_id' in update_data:
|
||||||
|
grocery = db.query(models.Grocery).filter(models.Grocery.id == update_data['grocery_id']).first()
|
||||||
|
if grocery is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||||
|
|
||||||
|
# Validate brand exists if brand_id is being updated
|
||||||
|
if 'brand_id' in update_data and update_data['brand_id'] is not None:
|
||||||
|
brand = db.query(models.Brand).filter(models.Brand.id == update_data['brand_id']).first()
|
||||||
|
if brand is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Brand not found")
|
||||||
|
|
||||||
update_data = grocery_update.dict(exclude_unset=True)
|
|
||||||
for field, value in update_data.items():
|
for field, value in update_data.items():
|
||||||
setattr(grocery, field, value)
|
setattr(product, field, value)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(grocery)
|
db.refresh(product)
|
||||||
return grocery
|
return product
|
||||||
|
|
||||||
@app.delete("/groceries/{grocery_id}")
|
@app.delete("/products/{product_id}")
|
||||||
def delete_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
def delete_product(product_id: int, db: Session = Depends(get_db)):
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
if grocery is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
db.delete(grocery)
|
db.delete(product)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Grocery deleted successfully"}
|
return {"message": "Product deleted successfully"}
|
||||||
|
|
||||||
# Shop endpoints
|
# Shop endpoints
|
||||||
@app.post("/shops/", response_model=schemas.Shop)
|
@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:
|
if shop is None:
|
||||||
raise HTTPException(status_code=404, detail="Shop not found")
|
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():
|
for field, value in update_data.items():
|
||||||
setattr(shop, field, value)
|
setattr(shop, field, value)
|
||||||
|
|
||||||
@ -159,6 +219,177 @@ def delete_shop(shop_id: int, db: Session = Depends(get_db)):
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Shop deleted successfully"}
|
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
|
# Shopping Event endpoints
|
||||||
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
||||||
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
||||||
@ -178,19 +409,19 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
|
|||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_event)
|
db.refresh(db_event)
|
||||||
|
|
||||||
# Add groceries to the event
|
# Add products to the event
|
||||||
for grocery_item in event.groceries:
|
for product_item in event.products:
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
|
product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
|
||||||
if grocery is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
|
raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
|
||||||
|
|
||||||
# Insert into association table
|
# Insert into association table
|
||||||
db.execute(
|
db.execute(
|
||||||
models.shopping_event_groceries.insert().values(
|
models.shopping_event_products.insert().values(
|
||||||
shopping_event_id=db_event.id,
|
shopping_event_id=db_event.id,
|
||||||
grocery_id=grocery_item.grocery_id,
|
product_id=product_item.product_id,
|
||||||
amount=grocery_item.amount,
|
amount=product_item.amount,
|
||||||
price=grocery_item.price
|
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.total_amount = event_update.total_amount
|
||||||
event.notes = event_update.notes
|
event.notes = event_update.notes
|
||||||
|
|
||||||
# Remove existing grocery associations
|
# Remove existing product associations
|
||||||
db.execute(
|
db.execute(
|
||||||
models.shopping_event_groceries.delete().where(
|
models.shopping_event_products.delete().where(
|
||||||
models.shopping_event_groceries.c.shopping_event_id == event_id
|
models.shopping_event_products.c.shopping_event_id == event_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Add new grocery associations
|
# Add new product associations
|
||||||
for grocery_item in event_update.groceries:
|
for product_item in event_update.products:
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
|
product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
|
||||||
if grocery is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
|
raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
|
||||||
|
|
||||||
# Insert into association table
|
# Insert into association table
|
||||||
db.execute(
|
db.execute(
|
||||||
models.shopping_event_groceries.insert().values(
|
models.shopping_event_products.insert().values(
|
||||||
shopping_event_id=event_id,
|
shopping_event_id=event_id,
|
||||||
grocery_id=grocery_item.grocery_id,
|
product_id=product_item.product_id,
|
||||||
amount=grocery_item.amount,
|
amount=product_item.amount,
|
||||||
price=grocery_item.price
|
price=product_item.price
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -261,10 +492,10 @@ def delete_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
|||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||||
|
|
||||||
# Delete grocery associations first
|
# Delete product associations first
|
||||||
db.execute(
|
db.execute(
|
||||||
models.shopping_event_groceries.delete().where(
|
models.shopping_event_products.delete().where(
|
||||||
models.shopping_event_groceries.c.shopping_event_id == event_id
|
models.shopping_event_products.c.shopping_event_id == event_id
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -6,23 +6,59 @@ from datetime import datetime
|
|||||||
|
|
||||||
Base = declarative_base()
|
Base = declarative_base()
|
||||||
|
|
||||||
# Association table for many-to-many relationship between shopping events and groceries
|
# Association table for many-to-many relationship between shopping events and products
|
||||||
shopping_event_groceries = Table(
|
shopping_event_products = Table(
|
||||||
'shopping_event_groceries',
|
'shopping_event_products',
|
||||||
Base.metadata,
|
Base.metadata,
|
||||||
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
|
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
|
||||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
|
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
|
||||||
Column('grocery_id', Integer, ForeignKey('groceries.id'), nullable=False),
|
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
|
||||||
Column('amount', Float, nullable=False), # Amount of this grocery bought in this event
|
Column('amount', Float, nullable=False), # Amount of this product bought in this event
|
||||||
Column('price', Float, nullable=False) # Price of this grocery at the time of this shopping 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):
|
class Grocery(Base):
|
||||||
__tablename__ = "groceries"
|
__tablename__ = "groceries"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, nullable=False, index=True)
|
name = Column(String, nullable=False, index=True)
|
||||||
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)
|
organic = Column(Boolean, default=False)
|
||||||
weight = Column(Float, nullable=True) # in grams or kg
|
weight = Column(Float, nullable=True) # in grams or kg
|
||||||
weight_unit = Column(String, default="piece") # "g", "kg", "ml", "l", "piece"
|
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())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
# 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):
|
class Shop(Base):
|
||||||
__tablename__ = "shops"
|
__tablename__ = "shops"
|
||||||
@ -40,6 +78,7 @@ class Shop(Base):
|
|||||||
city = Column(String, nullable=False)
|
city = Column(String, nullable=False)
|
||||||
address = Column(String, nullable=True)
|
address = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
shopping_events = relationship("ShoppingEvent", back_populates="shop")
|
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
|
total_amount = Column(Float, nullable=True) # Total cost of the shopping event
|
||||||
notes = Column(String, nullable=True)
|
notes = Column(String, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
shop = relationship("Shop", back_populates="shopping_events")
|
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>=7.4.3
|
||||||
pytest-asyncio>=0.21.1
|
pytest-asyncio>=0.21.1
|
||||||
httpx>=0.25.2
|
httpx>=0.25.2
|
||||||
|
requests>=2.31.0
|
||||||
@ -22,7 +22,7 @@ def main():
|
|||||||
backend_dir = Path(__file__).parent
|
backend_dir = Path(__file__).parent
|
||||||
os.chdir(backend_dir)
|
os.chdir(backend_dir)
|
||||||
|
|
||||||
print("🍃 Starting Grocery Tracker Backend Development Server")
|
print("🍃 Starting Product Tracker Backend Development Server")
|
||||||
print("=" * 50)
|
print("=" * 50)
|
||||||
|
|
||||||
# Check if virtual environment exists
|
# Check if virtual environment exists
|
||||||
|
|||||||
@ -2,28 +2,89 @@ from pydantic import BaseModel, Field
|
|||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from datetime import datetime
|
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):
|
class GroceryBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
category: str
|
category_id: int
|
||||||
organic: bool = False
|
|
||||||
weight: Optional[float] = None
|
|
||||||
weight_unit: str = "g"
|
|
||||||
|
|
||||||
class GroceryCreate(GroceryBase):
|
class GroceryCreate(GroceryBase):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
class GroceryUpdate(BaseModel):
|
class GroceryUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
category: Optional[str] = None
|
category_id: Optional[int] = None
|
||||||
organic: Optional[bool] = None
|
|
||||||
weight: Optional[float] = None
|
|
||||||
weight_unit: Optional[str] = None
|
|
||||||
|
|
||||||
class Grocery(GroceryBase):
|
class Grocery(GroceryBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
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:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@ -45,20 +106,22 @@ class ShopUpdate(BaseModel):
|
|||||||
class Shop(ShopBase):
|
class Shop(ShopBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
# Shopping Event schemas
|
# Shopping Event schemas
|
||||||
class GroceryInEvent(BaseModel):
|
class ProductInEvent(BaseModel):
|
||||||
grocery_id: int
|
product_id: int
|
||||||
amount: float = Field(..., gt=0)
|
amount: float = Field(..., gt=0)
|
||||||
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
|
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
|
||||||
|
|
||||||
class GroceryWithEventData(BaseModel):
|
class ProductWithEventData(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
category: str
|
grocery: Grocery
|
||||||
|
brand: Optional[Brand] = None
|
||||||
organic: bool
|
organic: bool
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
weight_unit: str
|
weight_unit: str
|
||||||
@ -75,20 +138,21 @@ class ShoppingEventBase(BaseModel):
|
|||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
|
|
||||||
class ShoppingEventCreate(ShoppingEventBase):
|
class ShoppingEventCreate(ShoppingEventBase):
|
||||||
groceries: List[GroceryInEvent] = []
|
products: List[ProductInEvent] = []
|
||||||
|
|
||||||
class ShoppingEventUpdate(BaseModel):
|
class ShoppingEventUpdate(BaseModel):
|
||||||
shop_id: Optional[int] = None
|
shop_id: Optional[int] = None
|
||||||
date: Optional[datetime] = None
|
date: Optional[datetime] = None
|
||||||
total_amount: Optional[float] = Field(None, ge=0)
|
total_amount: Optional[float] = Field(None, ge=0)
|
||||||
notes: Optional[str] = None
|
notes: Optional[str] = None
|
||||||
groceries: Optional[List[GroceryInEvent]] = None
|
products: Optional[List[ProductInEvent]] = None
|
||||||
|
|
||||||
class ShoppingEventResponse(ShoppingEventBase):
|
class ShoppingEventResponse(ShoppingEventBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
shop: Shop
|
shop: Shop
|
||||||
groceries: List[GroceryWithEventData] = []
|
products: List[ProductWithEventData] = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "grocery-tracker-frontend",
|
"name": "product-tracker-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/node": "^20.10.5",
|
"@types/node": "^20.10.5",
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "grocery-tracker-frontend",
|
"name": "product-tracker-frontend",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@ -7,9 +7,9 @@
|
|||||||
<meta name="theme-color" content="#000000" />
|
<meta name="theme-color" content="#000000" />
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
echo "🎯 Setting up Grocery Tracker Frontend"
|
echo "🎯 Setting up Product Tracker Frontend"
|
||||||
echo "======================================"
|
echo "======================================"
|
||||||
|
|
||||||
# Check if Node.js is installed
|
# Check if Node.js is installed
|
||||||
|
|||||||
@ -1,72 +1,122 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } 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 Dashboard from './components/Dashboard';
|
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() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
{/* Navigation */}
|
<Navigation />
|
||||||
<nav className="bg-white shadow-lg">
|
<main className="py-10">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<Routes>
|
||||||
<div className="flex">
|
<Route path="/" element={<Dashboard />} />
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||||
<h1 className="text-xl font-bold text-gray-800">
|
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
|
||||||
🛒 Grocery Tracker
|
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
||||||
</h1>
|
<Route path="/shops" element={<ShopList />} />
|
||||||
</div>
|
<Route path="/products" element={<ProductList />} />
|
||||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
<Route path="/brands" element={<BrandList />} />
|
||||||
<Link
|
<Route path="/groceries" element={<GroceryList />} />
|
||||||
to="/"
|
<Route path="/categories" element={<GroceryCategoryList />} />
|
||||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
</Routes>
|
||||||
>
|
|
||||||
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>
|
</div>
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Dashboard />} />
|
|
||||||
<Route path="/groceries" element={<GroceryList />} />
|
|
||||||
<Route path="/shops" element={<ShopList />} />
|
|
||||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
|
||||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
|
||||||
<Route path="/add-purchase" element={<ShoppingEventForm />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</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 React, { useState, useEffect } from 'react';
|
||||||
import { groceryApi } from '../services/api';
|
import { Grocery, GroceryCreate, GroceryCategory } from '../types';
|
||||||
import { Grocery } from '../types';
|
import { groceryApi, groceryCategoryApi } from '../services/api';
|
||||||
|
|
||||||
interface AddGroceryModalProps {
|
interface AddGroceryModalProps {
|
||||||
isOpen: boolean;
|
grocery?: Grocery | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onGroceryAdded: () => void;
|
|
||||||
editGrocery?: Grocery | null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroceryFormData {
|
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) => {
|
||||||
name: string;
|
const [formData, setFormData] = useState<GroceryCreate>({
|
||||||
category: string;
|
|
||||||
organic: boolean;
|
|
||||||
weight?: number;
|
|
||||||
weight_unit: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => {
|
|
||||||
const [formData, setFormData] = useState<GroceryFormData>({
|
|
||||||
name: '',
|
name: '',
|
||||||
category: '',
|
category_id: 0
|
||||||
organic: false,
|
|
||||||
weight: undefined,
|
|
||||||
weight_unit: 'piece'
|
|
||||||
});
|
});
|
||||||
|
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
const categories = [
|
const isEditMode = Boolean(grocery);
|
||||||
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry', 'Frozen',
|
|
||||||
'Bakery', 'Beverages', 'Snacks', 'Health & Beauty', 'Household', 'Other'
|
|
||||||
];
|
|
||||||
|
|
||||||
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
|
||||||
|
|
||||||
// Populate form when editing
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editGrocery) {
|
fetchCategories();
|
||||||
|
if (grocery) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: editGrocery.name,
|
name: grocery.name,
|
||||||
category: editGrocery.category,
|
category_id: grocery.category_id
|
||||||
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'
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setError('');
|
}, [grocery]);
|
||||||
}, [editGrocery, isOpen]);
|
|
||||||
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim() || !formData.category.trim()) {
|
setLoading(true);
|
||||||
setError('Please fill in all required fields with valid values');
|
setMessage('');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
if (isEditMode && grocery) {
|
||||||
setError('');
|
await groceryApi.update(grocery.id, formData);
|
||||||
|
setMessage('Grocery updated successfully!');
|
||||||
const groceryData = {
|
|
||||||
...formData,
|
|
||||||
weight: formData.weight || undefined
|
|
||||||
};
|
|
||||||
|
|
||||||
if (editGrocery) {
|
|
||||||
// Update existing grocery
|
|
||||||
await groceryApi.update(editGrocery.id, groceryData);
|
|
||||||
} else {
|
} else {
|
||||||
// Create new grocery
|
await groceryApi.create(formData);
|
||||||
await groceryApi.create(groceryData);
|
setMessage('Grocery created successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form
|
setTimeout(() => {
|
||||||
setFormData({
|
onClose();
|
||||||
name: '',
|
}, 1500);
|
||||||
category: '',
|
} catch (error) {
|
||||||
organic: false,
|
console.error('Error saving grocery:', error);
|
||||||
weight: undefined,
|
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
||||||
weight_unit: 'piece'
|
|
||||||
});
|
|
||||||
|
|
||||||
onGroceryAdded();
|
|
||||||
onClose();
|
|
||||||
} catch (err) {
|
|
||||||
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`);
|
|
||||||
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<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="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
{isEditMode ? 'Edit Grocery' : 'Add New Grocery'}
|
||||||
{editGrocery ? 'Edit Grocery' : 'Add New Grocery'}
|
</h3>
|
||||||
</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 && (
|
{message && (
|
||||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
<div className={`mb-4 p-4 rounded-md ${
|
||||||
{error}
|
message.includes('Error')
|
||||||
|
? 'bg-red-50 text-red-700'
|
||||||
|
: 'bg-green-50 text-green-700'
|
||||||
|
}`}>
|
||||||
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit}>
|
||||||
<div>
|
<div className="mb-4">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||||
Name *
|
Grocery Name
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="name"
|
|
||||||
name="name"
|
|
||||||
value={formData.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
|
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>
|
||||||
|
|
||||||
<div>
|
<div className="mb-4">
|
||||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||||
Category *
|
Category
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="category"
|
value={formData.category_id}
|
||||||
name="category"
|
onChange={(e) => setFormData({...formData, category_id: parseInt(e.target.value)})}
|
||||||
value={formData.category}
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
onChange={handleChange}
|
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
>
|
>
|
||||||
<option value="">Select a category</option>
|
<option value={0}>Select a category</option>
|
||||||
{categories.map(cat => (
|
{categories.map(category => (
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
<option key={category.id} value={category.id}>
|
||||||
|
{category.name}
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="flex justify-end space-x-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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@ -232,12 +125,12 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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"
|
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
|
{loading
|
||||||
? (editGrocery ? 'Updating...' : 'Adding...')
|
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||||
: (editGrocery ? 'Update Grocery' : 'Add Grocery')
|
: (isEditMode ? 'Update Grocery' : 'Create Grocery')
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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 className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<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="p-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<button
|
<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"
|
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">
|
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
||||||
@ -117,7 +117,7 @@ const Dashboard: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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"
|
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">
|
<div className="p-2 bg-green-100 rounded-md mr-3">
|
||||||
@ -126,8 +126,8 @@ const Dashboard: React.FC = () => {
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-gray-900">Add Grocery</p>
|
<p className="font-medium text-gray-900">Add Product</p>
|
||||||
<p className="text-sm text-gray-600">Add a new grocery item</p>
|
<p className="text-sm text-gray-600">Add a new product item</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@ -181,9 +181,9 @@ const Dashboard: React.FC = () => {
|
|||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{new Date(event.date).toLocaleDateString()}
|
{new Date(event.date).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
{event.groceries.length > 0 && (
|
{event.products.length > 0 && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 React, { useState, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { Grocery } from '../types';
|
import { Grocery } from '../types';
|
||||||
import { groceryApi } from '../services/api';
|
import { groceryApi } from '../services/api';
|
||||||
import AddGroceryModal from './AddGroceryModal';
|
import AddGroceryModal from './AddGroceryModal';
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
|
||||||
|
|
||||||
const GroceryList: React.FC = () => {
|
const GroceryList: React.FC = () => {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
||||||
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
|
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchGroceries();
|
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 () => {
|
const fetchGroceries = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await groceryApi.getAll();
|
const response = await groceryApi.getAll();
|
||||||
setGroceries(response.data);
|
setGroceries(response.data);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
setError('Failed to fetch groceries');
|
console.error('Error fetching groceries:', error);
|
||||||
console.error('Error fetching groceries:', err);
|
setMessage('Error loading groceries. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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) => {
|
const handleEdit = (grocery: Grocery) => {
|
||||||
setEditingGrocery(grocery);
|
setEditingGrocery(grocery);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (grocery: Grocery) => {
|
const handleModalClose = () => {
|
||||||
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 = () => {
|
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingGrocery(null);
|
setEditingGrocery(null);
|
||||||
};
|
fetchGroceries();
|
||||||
|
|
||||||
const handleCloseDeleteModal = () => {
|
|
||||||
setDeletingGrocery(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@ -86,115 +66,95 @@ const GroceryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="max-w-4xl mx-auto">
|
||||||
<div className="flex justify-between items-center">
|
<div className="bg-white shadow rounded-lg">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
<div className="px-4 py-5 sm:p-6">
|
||||||
<button
|
<div className="flex justify-between items-center mb-4">
|
||||||
onClick={() => {
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||||
setEditingGrocery(null);
|
Groceries
|
||||||
setIsModalOpen(true);
|
</h3>
|
||||||
}}
|
<button
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
onClick={() => setIsModalOpen(true)}
|
||||||
>
|
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
Add New Grocery
|
>
|
||||||
</button>
|
Add Grocery
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
|
||||||
{groceries.length === 0 ? (
|
|
||||||
<div className="text-center py-12">
|
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
|
||||||
</svg>
|
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
|
||||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
{message && (
|
||||||
<thead className="bg-gray-50">
|
<div className={`mb-4 p-4 rounded-md ${
|
||||||
<tr>
|
message.includes('Error') || message.includes('Cannot')
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
? 'bg-red-50 text-red-700'
|
||||||
Name
|
: 'bg-green-50 text-green-700'
|
||||||
</th>
|
}`}>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
{message}
|
||||||
Category
|
</div>
|
||||||
</th>
|
)}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Weight
|
{groceries.length === 0 ? (
|
||||||
</th>
|
<div className="text-center py-8">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
|
||||||
Organic
|
</div>
|
||||||
</th>
|
) : (
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||||
Actions
|
<table className="min-w-full divide-y divide-gray-300">
|
||||||
</th>
|
<thead className="bg-gray-50">
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
Name
|
||||||
{groceries.map((grocery) => (
|
</th>
|
||||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
Category
|
||||||
<div className="text-sm font-medium text-gray-900">{grocery.name}</div>
|
</th>
|
||||||
</td>
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
Created
|
||||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
</th>
|
||||||
{grocery.category}
|
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
</span>
|
Actions
|
||||||
</td>
|
</th>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
</tr>
|
||||||
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'}
|
</thead>
|
||||||
</td>
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
{groceries.map((grocery) => (
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||||
grocery.organic
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
? 'bg-green-100 text-green-800'
|
{grocery.name}
|
||||||
: 'bg-gray-100 text-gray-800'
|
</td>
|
||||||
}`}>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{grocery.organic ? 'Organic' : 'Conventional'}
|
{grocery.category.name}
|
||||||
</span>
|
</td>
|
||||||
</td>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
{new Date(grocery.created_at).toLocaleDateString()}
|
||||||
<button
|
</td>
|
||||||
onClick={() => handleEdit(grocery)}
|
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
<button
|
||||||
>
|
onClick={() => handleEdit(grocery)}
|
||||||
Edit
|
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||||
</button>
|
>
|
||||||
<button
|
Edit
|
||||||
onClick={() => handleDelete(grocery)}
|
</button>
|
||||||
className="text-red-600 hover:text-red-900"
|
<button
|
||||||
>
|
onClick={() => handleDelete(grocery.id)}
|
||||||
Delete
|
className="text-red-600 hover:text-red-900"
|
||||||
</button>
|
>
|
||||||
</td>
|
Delete
|
||||||
</tr>
|
</button>
|
||||||
))}
|
</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
))}
|
||||||
)}
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AddGroceryModal
|
{isModalOpen && (
|
||||||
isOpen={isModalOpen}
|
<AddGroceryModal
|
||||||
onClose={handleCloseModal}
|
grocery={editingGrocery}
|
||||||
onGroceryAdded={handleGroceryAdded}
|
onClose={handleModalClose}
|
||||||
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}
|
|
||||||
/>
|
|
||||||
</div>
|
</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>
|
</svg>
|
||||||
Added {new Date(shop.created_at).toLocaleDateString()}
|
Added {new Date(shop.created_at).toLocaleDateString()}
|
||||||
</div>
|
</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>
|
||||||
</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 { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types';
|
import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
|
||||||
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
|
import { shopApi, productApi, shoppingEventApi } from '../services/api';
|
||||||
|
|
||||||
const ShoppingEventForm: React.FC = () => {
|
const ShoppingEventForm: React.FC = () => {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [shops, setShops] = useState<Shop[]>([]);
|
const [shops, setShops] = useState<Shop[]>([]);
|
||||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingEvent, setLoadingEvent] = useState(false);
|
const [loadingEvent, setLoadingEvent] = useState(false);
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@ -19,61 +19,24 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
date: new Date().toISOString().split('T')[0],
|
date: new Date().toISOString().split('T')[0],
|
||||||
total_amount: undefined,
|
total_amount: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
groceries: []
|
products: []
|
||||||
});
|
});
|
||||||
|
|
||||||
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
|
const [selectedProducts, setSelectedProducts] = useState<ProductInEvent[]>([]);
|
||||||
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
|
const [newProductItem, setNewProductItem] = useState<ProductInEvent>({
|
||||||
grocery_id: 0,
|
product_id: 0,
|
||||||
amount: 1,
|
amount: 1,
|
||||||
price: 0
|
price: 0
|
||||||
});
|
});
|
||||||
const [autoCalculate, setAutoCalculate] = useState<boolean>(true);
|
const [autoCalculate, setAutoCalculate] = useState<boolean>(true);
|
||||||
|
|
||||||
useEffect(() => {
|
// Calculate total amount from selected products
|
||||||
fetchShops();
|
const calculateTotal = (products: ProductInEvent[]): number => {
|
||||||
fetchGroceries();
|
const total = products.reduce((total, item) => total + (item.amount * item.price), 0);
|
||||||
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);
|
|
||||||
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update total amount whenever selectedGroceries changes
|
const fetchShoppingEvent = useCallback(async (eventId: number) => {
|
||||||
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) => {
|
|
||||||
try {
|
try {
|
||||||
setLoadingEvent(true);
|
setLoadingEvent(true);
|
||||||
const response = await shoppingEventApi.getById(eventId);
|
const response = await shoppingEventApi.getById(eventId);
|
||||||
@ -86,15 +49,15 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
formattedDate = event.date.split('T')[0];
|
formattedDate = event.date.split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map groceries to the format we need
|
// Map products to the format we need
|
||||||
const mappedGroceries = event.groceries.map(g => ({
|
const mappedProducts = event.products.map(p => ({
|
||||||
grocery_id: g.id,
|
product_id: p.id,
|
||||||
amount: g.amount,
|
amount: p.amount,
|
||||||
price: g.price
|
price: p.price
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Calculate the sum of all groceries
|
// Calculate the sum of all products
|
||||||
const calculatedTotal = calculateTotal(mappedGroceries);
|
const calculatedTotal = calculateTotal(mappedProducts);
|
||||||
|
|
||||||
// Check if existing total matches calculated total (with small tolerance for floating point)
|
// Check if existing total matches calculated total (with small tolerance for floating point)
|
||||||
const existingTotal = event.total_amount || 0;
|
const existingTotal = event.total_amount || 0;
|
||||||
@ -105,10 +68,10 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
date: formattedDate,
|
date: formattedDate,
|
||||||
total_amount: event.total_amount,
|
total_amount: event.total_amount,
|
||||||
notes: event.notes || '',
|
notes: event.notes || '',
|
||||||
groceries: []
|
products: []
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedGroceries(mappedGroceries);
|
setSelectedProducts(mappedProducts);
|
||||||
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching shopping event:', error);
|
console.error('Error fetching shopping event:', error);
|
||||||
@ -116,29 +79,66 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingEvent(false);
|
setLoadingEvent(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
const addGroceryToEvent = () => {
|
useEffect(() => {
|
||||||
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price >= 0) {
|
fetchShops();
|
||||||
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
|
fetchProducts();
|
||||||
setNewGroceryItem({ grocery_id: 0, amount: 1, price: 0 });
|
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) => {
|
const fetchProducts = async () => {
|
||||||
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
|
try {
|
||||||
|
const response = await productApi.getAll();
|
||||||
|
setProducts(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching products:', error);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const editGroceryFromEvent = (index: number) => {
|
const addProductToEvent = () => {
|
||||||
const groceryToEdit = selectedGroceries[index];
|
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
||||||
// Load the grocery data into the input fields
|
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
||||||
setNewGroceryItem({
|
setNewProductItem({ product_id: 0, amount: 1, price: 0 });
|
||||||
grocery_id: groceryToEdit.grocery_id,
|
}
|
||||||
amount: groceryToEdit.amount,
|
};
|
||||||
price: groceryToEdit.price
|
|
||||||
|
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
|
// 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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
@ -149,7 +149,7 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
try {
|
try {
|
||||||
const eventData = {
|
const eventData = {
|
||||||
...formData,
|
...formData,
|
||||||
groceries: selectedGroceries
|
products: selectedProducts
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode) {
|
||||||
@ -173,9 +173,9 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
date: new Date().toISOString().split('T')[0],
|
date: new Date().toISOString().split('T')[0],
|
||||||
total_amount: undefined,
|
total_amount: undefined,
|
||||||
notes: '',
|
notes: '',
|
||||||
groceries: []
|
products: []
|
||||||
});
|
});
|
||||||
setSelectedGroceries([]);
|
setSelectedProducts([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Full error object:', error);
|
console.error('Full error object:', error);
|
||||||
@ -185,12 +185,13 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGroceryName = (id: number) => {
|
const getProductName = (id: number) => {
|
||||||
const grocery = groceries.find(g => g.id === id);
|
const product = products.find(p => p.id === id);
|
||||||
if (!grocery) return 'Unknown';
|
if (!product) return 'Unknown';
|
||||||
|
|
||||||
const weightInfo = grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit;
|
const weightInfo = product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit;
|
||||||
return `${grocery.name} ${weightInfo}`;
|
const organicEmoji = product.organic ? ' 🌱' : '';
|
||||||
|
return `${product.name}${organicEmoji} ${weightInfo}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingEvent) {
|
if (loadingEvent) {
|
||||||
@ -264,25 +265,25 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Groceries Section */}
|
{/* Add Products Section */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Add Groceries
|
Add Products
|
||||||
</label>
|
</label>
|
||||||
<div className="flex space-x-2 mb-4">
|
<div className="flex space-x-2 mb-4">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
Grocery
|
Product
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={newGroceryItem.grocery_id}
|
value={newProductItem.product_id}
|
||||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
|
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"
|
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>
|
<option value={0}>Select a product</option>
|
||||||
{groceries.map(grocery => (
|
{products.map(product => (
|
||||||
<option key={grocery.id} value={grocery.id}>
|
<option key={product.id} value={product.id}>
|
||||||
{grocery.name} ({grocery.category}) {grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit}
|
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category.name}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@ -296,8 +297,8 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
step="1"
|
step="1"
|
||||||
min="1"
|
min="1"
|
||||||
placeholder="1"
|
placeholder="1"
|
||||||
value={newGroceryItem.amount}
|
value={newProductItem.amount}
|
||||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
|
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"
|
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>
|
||||||
@ -310,15 +311,15 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
step="0.01"
|
step="0.01"
|
||||||
min="0"
|
min="0"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
value={newGroceryItem.price}
|
value={newProductItem.price}
|
||||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, price: parseFloat(e.target.value)})}
|
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"
|
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>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addGroceryToEvent}
|
onClick={addProductToEvent}
|
||||||
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
|
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
@ -326,15 +327,15 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Groceries List */}
|
{/* Selected Products List */}
|
||||||
{selectedGroceries.length > 0 && (
|
{selectedProducts.length > 0 && (
|
||||||
<div className="bg-gray-50 rounded-md p-4">
|
<div className="bg-gray-50 rounded-md p-4">
|
||||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
<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 key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-sm text-gray-900">
|
<div className="text-sm text-gray-900">
|
||||||
{getGroceryName(item.grocery_id)}
|
{getProductName(item.product_id)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-600">
|
<div className="text-xs text-gray-600">
|
||||||
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
|
{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">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => editGroceryFromEvent(index)}
|
onClick={() => editProductFromEvent(index)}
|
||||||
className="text-blue-500 hover:text-blue-700"
|
className="text-blue-500 hover:text-blue-700"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeGroceryFromEvent(index)}
|
onClick={() => removeProductFromEvent(index)}
|
||||||
className="text-red-500 hover:text-red-700"
|
className="text-red-500 hover:text-red-700"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
@ -430,7 +431,7 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
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 ${
|
className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
isEditMode
|
isEditMode
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
? 'bg-blue-600 hover:bg-blue-700'
|
||||||
|
|||||||
@ -66,7 +66,7 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
||||||
<button
|
<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"
|
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
>
|
>
|
||||||
Add New Event
|
Add New Event
|
||||||
@ -109,15 +109,17 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{event.groceries.length > 0 && (
|
{event.products.length > 0 && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
|
<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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
{event.groceries.map((grocery) => (
|
{event.products.map((product) => (
|
||||||
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
|
<div key={product.id} className="bg-gray-50 rounded px-3 py-2">
|
||||||
<div className="text-sm text-gray-900">{grocery.name}</div>
|
<div className="text-sm text-gray-900">
|
||||||
|
{product.name} {product.organic ? '🌱' : ''}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-600">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@ -133,9 +135,17 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
<div className="flex justify-between items-center text-sm">
|
||||||
<span className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
Event #{event.id} • {new Date(event.created_at).toLocaleDateString()}
|
<div>Event #{event.id} • Created {new Date(event.created_at).toLocaleDateString()}</div>
|
||||||
</span>
|
{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">
|
<div className="flex space-x-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}
|
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}
|
||||||
|
|||||||
@ -1,23 +1,34 @@
|
|||||||
import axios from 'axios';
|
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate } from '../types';
|
||||||
import { Grocery, GroceryCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
|
|
||||||
|
|
||||||
const BASE_URL = 'http://localhost:8000';
|
const API_BASE_URL = 'http://localhost:8000';
|
||||||
|
|
||||||
const api = axios.create({
|
const api = {
|
||||||
baseURL: BASE_URL,
|
get: <T>(url: string): Promise<{ data: T }> =>
|
||||||
headers: {
|
fetch(`${API_BASE_URL}${url}`).then(res => res.json()).then(data => ({ data })),
|
||||||
'Content-Type': 'application/json',
|
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
|
// Product API functions
|
||||||
export const groceryApi = {
|
export const productApi = {
|
||||||
getAll: () => api.get<Grocery[]>('/groceries/'),
|
getAll: () => api.get<Product[]>('/products/'),
|
||||||
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
|
getById: (id: number) => api.get<Product>(`/products/${id}`),
|
||||||
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
|
create: (product: ProductCreate) => api.post<Product>('/products/', product),
|
||||||
update: (id: number, grocery: Partial<GroceryCreate>) =>
|
update: (id: number, product: Partial<ProductCreate>) =>
|
||||||
api.put<Grocery>(`/groceries/${id}`, grocery),
|
api.put<Product>(`/products/${id}`, product),
|
||||||
delete: (id: number) => api.delete(`/groceries/${id}`),
|
delete: (id: number) => api.delete(`/products/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shop API functions
|
// Shop API functions
|
||||||
@ -30,17 +41,45 @@ export const shopApi = {
|
|||||||
delete: (id: number) => api.delete(`/shops/${id}`),
|
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
|
// Shopping Event API functions
|
||||||
export const shoppingEventApi = {
|
export const shoppingEventApi = {
|
||||||
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
|
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
|
||||||
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
|
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
|
||||||
create: (event: ShoppingEventCreate) =>
|
create: (event: ShoppingEventCreate) => api.post<ShoppingEvent>('/shopping-events/', event),
|
||||||
api.post<ShoppingEvent>('/shopping-events/', event),
|
|
||||||
update: (id: number, event: ShoppingEventCreate) =>
|
update: (id: number, event: ShoppingEventCreate) =>
|
||||||
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
|
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
|
||||||
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Grocery API functions
|
||||||
|
export const groceryApi = {
|
||||||
|
getAll: () => api.get<Grocery[]>('/groceries/'),
|
||||||
|
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
|
||||||
|
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
|
||||||
|
update: (id: number, grocery: Partial<GroceryCreate>) =>
|
||||||
|
api.put<Grocery>(`/groceries/${id}`, grocery),
|
||||||
|
delete: (id: number) => api.delete(`/groceries/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
// Statistics API functions
|
// Statistics API functions
|
||||||
export const statsApi = {
|
export const statsApi = {
|
||||||
getCategories: () => api.get('/stats/categories'),
|
getCategories: () => api.get('/stats/categories'),
|
||||||
|
|||||||
@ -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 {
|
export interface Grocery {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
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;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
@ -9,9 +48,10 @@ export interface Grocery {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroceryCreate {
|
export interface ProductCreate {
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
grocery_id: number;
|
||||||
|
brand_id?: number;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
@ -23,6 +63,7 @@ export interface Shop {
|
|||||||
city: string;
|
city: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShopCreate {
|
export interface ShopCreate {
|
||||||
@ -31,16 +72,17 @@ export interface ShopCreate {
|
|||||||
address?: string | null;
|
address?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroceryInEvent {
|
export interface ProductInEvent {
|
||||||
grocery_id: number;
|
product_id: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
price: number;
|
price: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GroceryWithEventData {
|
export interface ProductWithEventData {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
grocery: Grocery;
|
||||||
|
brand?: Brand;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
@ -55,8 +97,9 @@ export interface ShoppingEvent {
|
|||||||
total_amount?: number;
|
total_amount?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
shop: Shop;
|
shop: Shop;
|
||||||
groceries: GroceryWithEventData[];
|
products: ProductWithEventData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingEventCreate {
|
export interface ShoppingEventCreate {
|
||||||
@ -64,7 +107,7 @@ export interface ShoppingEventCreate {
|
|||||||
date?: string;
|
date?: string;
|
||||||
total_amount?: number;
|
total_amount?: number;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
groceries: GroceryInEvent[];
|
products: ProductInEvent[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryStats {
|
export interface CategoryStats {
|
||||||
|
|||||||
20
package.json
20
package.json
@ -1,15 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "grocery-tracker-frontend",
|
"name": "product-tracker-frontend",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"description": "React frontend for grocery price tracking application",
|
"description": "React frontend for product price tracking application",
|
||||||
"private": true,
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "cd frontend && npm run dev",
|
"dev": "cd frontend && npm run dev",
|
||||||
"build": "cd frontend && npm run build",
|
"build": "cd frontend && npm run build",
|
||||||
"install:frontend": "cd frontend && npm install",
|
"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": "",
|
"author": "",
|
||||||
"license": "MIT"
|
"license": "ISC",
|
||||||
|
"dependencies": {},
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user