Compare commits

...

42 Commits

Author SHA1 Message Date
8fe45ad63b date error for product edit 2025-05-30 12:59:13 +02:00
9af2fa5c7f add favicon 2025-05-30 12:53:04 +02:00
3e9ad2dcb1 fix trigger creation bug 2025-05-30 10:55:42 +02:00
df8209e86d Remove resources folder from tracking 2025-05-30 10:43:14 +02:00
fa730b3b8e delete migration script 2025-05-30 10:41:40 +02:00
0b42a74fe9 Minor version bump (1.x.0) is appropriate because:
 New functionality added (soft delete system)
 Backward compatible (existing features unchanged)
 Significant enhancement (complete temporal tracking system)
 API additions (new endpoints, parameters)
 UI enhancements (new components, visual indicators)
2025-05-30 09:49:26 +02:00
56c3c16f6d Add duplicate button to ProductList 2025-05-28 13:51:14 +02:00
1a8c0587ee remove import / export on mobile 2025-05-28 12:41:16 +02:00
2afa7dbebf Add version number and fix warnings 2025-05-28 12:37:03 +02:00
521a0d6937 fixed width for date input 2025-05-28 12:21:32 +02:00
69a0872029 Fixed issue scrolling on mobile 2025-05-28 12:18:13 +02:00
87033d7f9a add mobile support 2025-05-28 11:31:36 +02:00
eb3ae05425 redesing product lists 2025-05-28 11:01:42 +02:00
330124837f fix error and update readme 2025-05-28 09:28:28 +02:00
666ce5d4d4 fix db errror 2025-05-28 09:25:24 +02:00
112ea41e88 remove intermediate grocery table and add related_products feature 2025-05-28 09:22:47 +02:00
3ea5db4214 validation import bug 2025-05-28 00:20:07 +02:00
03d80b99dc import / export feature 2025-05-28 00:10:54 +02:00
2846bcbb1c brands-in-shops feature implemented 2025-05-27 23:41:04 +02:00
7037be370e sorting in tables 2025-05-27 23:14:03 +02:00
629a89524c Stardize frontend layout 2025-05-27 22:59:45 +02:00
e20d0f0524 adjust api endpoint 2025-05-27 17:35:12 +02:00
a97554bf32 fix localhost 2025-05-27 15:20:05 +02:00
81575e401d docker version fix 2025-05-27 15:01:07 +02:00
f674c19a67 add init-db.sql 2025-05-27 14:43:00 +02:00
b68f1b51ce update readme 2025-05-26 21:59:31 +02:00
f88a931008 - add grocery category
- add Dockerfile
2025-05-26 21:55:49 +02:00
6118415f05 cleanup 2025-05-26 21:13:49 +02:00
7e24d58a94 add grocery to product 2025-05-26 21:05:15 +02:00
25c09dfecc add Brand Management 2025-05-26 20:44:15 +02:00
d27871160e rename grocery to product 2025-05-26 20:20:21 +02:00
1b984d18d9 add 🌱 for organic groceries 2025-05-26 12:36:39 +02:00
71b36f7749 create script for test data 2025-05-26 11:10:58 +02:00
5cd9d65e00 add updated_at fields 2025-05-26 11:10:42 +02:00
28db52dc2e Update datamodel in documentation 2025-05-26 09:05:39 +02:00
19a410d553 Handling of total amount in shopping events 2025-05-26 08:43:14 +02:00
8b2e4408fc add artificial key for shopping_event_groceries 2025-05-25 22:54:08 +02:00
b81379432b accept price 0 2025-05-25 20:44:20 +02:00
4f898054ff refactor: Merge add/edit shopping event forms into single component
• Consolidate ShoppingEventForm and EditShoppingEvent into one component
• Use URL parameters to detect add vs edit mode
• Eliminate code duplication while maintaining all functionality
• Remove obsolete EditShoppingEvent.tsx component
2025-05-25 20:42:52 +02:00
2fadb2d991 feat: Implement comprehensive edit functionality and standardize UI components
• Add full edit functionality for groceries with modal support
• Standardize delete confirmations across all components using ConfirmDeleteModal
• Implement complete shopping event edit functionality:
  - Create EditShoppingEvent component with full form capabilities
  - Add missing backend PUT endpoint for shopping events
  - Support editing all event data (shop, date, groceries, amounts, prices, notes)
• Add inline grocery edit functionality in shopping event forms:
  - Allow editing grocery items within add/edit forms
  - Load selected items back into input fields for modification
• Fix date timezone issues in edit forms to prevent date shifting
• Remove non-functional "View Details" button in favor of working Edit button
• Enhance user experience with consistent edit/delete patterns across the app

Breaking changes: None
Backend: Added PUT /shopping-events/{id} and DELETE /shopping-events/{id} endpoints
Frontend: Complete edit workflow for all entities with improved UX
2025-05-25 18:51:47 +02:00
500cb8983c feat: implement shop CRUD operations and fix address deletion bug
- Add shop creation, editing, and deletion functionality
- Create AddShopModal and ConfirmDeleteModal components
- Add missing backend PUT/DELETE endpoints for shops
- Fix address field not clearing when edited to empty value
2025-05-25 12:56:56 +02:00
cd39ac1fe8 fix add grocery 2025-05-24 22:52:10 +02:00
54 changed files with 8533 additions and 805 deletions

7
.gitignore vendored
View File

@@ -153,8 +153,12 @@ dist/
*.sqlite *.sqlite
*.sqlite3 *.sqlite3
# import data
resources/
# PostgreSQL # PostgreSQL
*.sql *.sql
!init-db.sql
# Database dumps # Database dumps
*.dump *.dump
@@ -214,6 +218,9 @@ $RECYCLE.BIN/
# Linux # Linux
*~ *~
# Temporary files starting with .$
.$*
# Logs # Logs
logs logs
*.log *.log

370
DOCKER_DEPLOYMENT.md Normal file
View File

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

View File

@@ -1,6 +1,6 @@
# 🚀 Quick Start Guide # 🚀 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! 🛒

381
README.md
View File

@@ -1,15 +1,65 @@
# 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.
## Table of Contents
- [Features](#features)
- [Quick Start with Docker](#quick-start-with-docker)
- [Architecture](#architecture)
- [Data Model](#data-model)
- [Development Setup](#development-setup)
- [API Endpoints](#api-endpoints)
- [Usage](#usage)
- [Deployment](#deployment)
- [Development](#development)
- [Contributing](#contributing)
## 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 - **Brand Management**: Track product brands and their availability in different shops
- **Shopping Events**: Record purchases with multiple products and amounts
- **Price Tracking**: Monitor price changes over time - **Price Tracking**: Monitor price changes over time
- **Import/Export**: Bulk import and export data via CSV files
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS - **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
## Quick Start with Docker
The fastest way to get the application running is with Docker Compose:
### Prerequisites
- Docker Engine 20.10+
- Docker Compose 2.0+
### Deploy in 3 Steps
1. **Clone and setup:**
```bash
git clone <your-repo-url>
cd groceries
cp docker.env.example .env
# Edit .env with your secure passwords
```
2. **Start all services:**
```bash
docker-compose up -d
```
3. **Initialize database:**
```bash
docker-compose exec backend alembic upgrade head
```
### Access Your Application
- **Frontend**: http://localhost
- **Backend API**: http://localhost:8000
- **API Documentation**: http://localhost:8000/docs
For detailed Docker deployment instructions, see [DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md).
## Architecture ## Architecture
### Technology Stack ### Technology Stack
@@ -25,8 +75,12 @@ A web application for tracking grocery prices and shopping events. Built with Fa
- React 18 with TypeScript - React 18 with TypeScript
- React Router - Client-side routing - React Router - Client-side routing
- Tailwind CSS - Utility-first CSS framework - Tailwind CSS - Utility-first CSS framework
- Axios - HTTP client for API calls - PapaParse - CSV parsing for import/export
- React Hook Form - Form handling
**Deployment:**
- Docker & Docker Compose
- Nginx - Web server and reverse proxy
- PostgreSQL - Production database
### Component Communication ### Component Communication
@@ -34,36 +88,171 @@ A web application for tracking grocery prices and shopping events. Built with Fa
┌─────────────────┐ HTTP/REST API ┌─────────────────┐ SQL Queries ┌─────────────────┐ ┌─────────────────┐ HTTP/REST API ┌─────────────────┐ SQL Queries ┌─────────────────┐
│ React │ ←─────────────────→ │ FastAPI │ ←───────────────→ │ PostgreSQL │ │ React │ ←─────────────────→ │ FastAPI │ ←───────────────→ │ PostgreSQL │
│ Frontend │ JSON requests │ Backend │ SQLAlchemy ORM │ Database │ │ Frontend │ JSON requests │ Backend │ SQLAlchemy ORM │ Database │
│ (Port 3000) │ JSON responses │ (Port 8000) │ │ (Port 5432) │ │ (Port 80) │ JSON responses │ (Port 8000) │ │ (Port 5432) │
└─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘ └─────────────────┘
``` ```
## Data Model ## Data Model
### Groceries ### Core Entities
- `id`: Primary key
- `name`: Grocery name
- `price`: Current price
- `category`: Food category
- `organic`: Boolean flag
- `weight`: Weight/volume
- `weight_unit`: Unit (g, kg, ml, l, piece)
### Shops #### Brands (`brands` table)
- `id`: Primary key - `id`: Integer, Primary key, Auto-increment
- `name`: Shop name - `name`: String, Brand name (indexed, required)
- `city`: Location city - `created_at`: DateTime, Creation timestamp (auto-generated)
- `address`: Optional full address - `updated_at`: DateTime, Last update timestamp (auto-updated)
### Shopping Events #### Grocery Categories (`grocery_categories` table)
- `id`: Primary key - `id`: Integer, Primary key, Auto-increment
- `shop_id`: Foreign key to shops - `name`: String, Category name (indexed, required)
- `date`: Purchase date - `created_at`: DateTime, Creation timestamp (auto-generated)
- `total_amount`: Optional total cost - `updated_at`: DateTime, Last update timestamp (auto-updated)
- `notes`: Optional notes
- `groceries`: Many-to-many relationship with amounts
## Setup Instructions #### Products (`products` table)
- `id`: Integer, Primary key, Auto-increment
- `name`: String, Product name (indexed, required)
- `category_id`: Integer, Foreign key to grocery_categories (required)
- `brand_id`: Integer, Foreign key to brands (optional)
- `organic`: Boolean, Organic flag (default: false)
- `weight`: Float, Weight/volume (optional)
- `weight_unit`: String, Unit of measurement (default: "piece")
- Supported units: "g", "kg", "lb", "oz", "ml", "l", "piece"
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
#### Shops (`shops` table)
- `id`: Integer, Primary key, Auto-increment
- `name`: String, Shop name (indexed, required)
- `city`: String, Location city (required)
- `address`: String, Full address (optional)
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
#### Shopping Events (`shopping_events` table)
- `id`: Integer, Primary key, Auto-increment
- `shop_id`: Integer, Foreign key to shops (required)
- `date`: DateTime, Purchase date (required, default: current time)
- `total_amount`: Float, Total cost of shopping event (optional, auto-calculated)
- `notes`: String, Optional notes about the purchase
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
#### Brands in Shops (`brands_in_shops` table)
Association table tracking which brands are available in which shops:
- `id`: Integer, Primary key, Auto-increment
- `shop_id`: Integer, Foreign key to shops (required)
- `brand_id`: Integer, Foreign key to brands (required)
- `created_at`: DateTime, Creation timestamp (auto-generated)
- `updated_at`: DateTime, Last update timestamp (auto-updated)
### Association Tables
#### Shopping Event Products (`shopping_event_products` table)
Many-to-many relationship between shopping events and products with additional data:
- `id`: Integer, Primary key, Auto-increment
- `shopping_event_id`: Integer, Foreign key to shopping_events (required)
- `product_id`: Integer, Foreign key to products (required)
- `amount`: Float, Quantity purchased in this event (required, > 0)
- `price`: Float, Price at time of purchase (required, ≥ 0)
- `discount`: Boolean, Whether the product was purchased with a discount (default: false)
#### Related Products (`related_products` table)
Many-to-many self-referential relationship between products for tracking related items:
- `id`: Integer, Primary key, Auto-increment
- `product_id`: Integer, Foreign key to products (required)
- `related_product_id`: Integer, Foreign key to products (required)
- `relationship_type`: String, Type of relationship (optional)
- Examples: "size_variant", "brand_variant", "similar", "alternative"
- `created_at`: DateTime, Creation timestamp (auto-generated)
### Relationships
```
┌─────────────────┐ 1:N ┌─────────────────┐ 1:N ┌─────────────────┐
│ Brands │ ────────→ │ Products │ ←──────── │ Grocery │
│ │ │ │ │ Categories │
│ • id │ │ • id │ │ • id │
│ • name │ │ • name │ │ • name │
│ • created_at │ │ • category_id │ │ • created_at │
│ • updated_at │ │ • brand_id │ │ • updated_at │
└─────────────────┘ │ • organic │ └─────────────────┘
│ │ • weight │
│ │ • weight_unit │
│ │ • created_at │
│ │ • updated_at │
│ └─────────────────┘
│ │ │
│ │ │ N:M (self-referential)
│ │ ▼
│ │ ┌─────────────────────────────┐
│ │ │ Related Products │
│ │ │ (Association Table) │
│ │ │ │
│ │ │ • id │
│ │ │ • product_id │
│ │ │ • related_product_id │
│ │ │ • relationship_type │
│ │ │ • created_at │
│ │ └─────────────────────────────┘
│ │
│ │ N:M
│ ▼
│ ┌─────────────────────────────┐
│ │ Shopping Event Products │
│ │ (Association Table) │
│ │ │
│ │ • id │
│ │ • shopping_event_id │
│ │ • product_id │
│ │ • amount │
│ │ • price │
│ │ • discount │
│ └─────────────────────────────┘
│ │
│ │ N:1
│ ▼
│ ┌─────────────────┐ 1:N ┌─────────────────┐
│ │ Shops │ ────────→ │ Shopping Events │
│ │ │ │ │
│ │ • id │ │ • id │
│ │ • name │ │ • shop_id │
│ │ • city │ │ • date │
│ │ • address │ │ • total_amount │
│ │ • created_at │ │ • notes │
│ │ • updated_at │ │ • created_at │
│ └─────────────────┘ │ • updated_at │
│ │ └─────────────────┘
│ │
│ │ N:M
│ ▼
│ ┌─────────────────────────────┐
└────────────→ │ Brands in Shops │
│ (Association Table) │
│ │
│ • id │
│ • shop_id │
│ • brand_id │
│ • created_at │
│ • updated_at │
└─────────────────────────────┘
```
### Key Features
- **Direct Product-Category Relationship**: Products are directly linked to categories for simplified organization
- **Brand Tracking**: Optional brand association for products with shop availability tracking
- **Related Products**: Track relationships between products (size variants, brand alternatives, similar items)
- **Price History**: Each product purchase stores the price at that time, enabling price tracking
- **Discount Tracking**: Track which products were purchased with discounts for better spending analysis
- **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples)
- **Auto-calculation**: Total amount can be automatically calculated from individual items
- **Free Items**: Supports items with price 0 (samples, promotions, etc.)
- **Shop-Brand Filtering**: Products can be filtered by brands available in specific shops
- **Audit Trail**: All entities have creation timestamps for tracking
- **Data Integrity**: Foreign key constraints ensure referential integrity
- **Import/Export**: CSV-based bulk data operations for all entity types
## Development Setup
### Prerequisites ### Prerequisites
@@ -92,7 +281,7 @@ A web application for tracking grocery prices and shopping events. Built with Fa
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
@@ -135,22 +324,56 @@ A web application for tracking grocery prices and shopping events. Built with Fa
## API Endpoints ## API Endpoints
### Groceries ### Brands
- `GET /groceries/` - List all groceries - `GET /brands/` - List all brands
- `POST /groceries/` - Create new grocery - `POST /brands/` - Create new brand
- `GET /groceries/{id}` - Get specific grocery - `GET /brands/{id}` - Get specific brand
- `PUT /groceries/{id}` - Update grocery - `PUT /brands/{id}` - Update brand
- `DELETE /groceries/{id}` - Delete grocery - `DELETE /brands/{id}` - Delete brand
### Grocery Categories
- `GET /grocery-categories/` - List all grocery categories
- `POST /grocery-categories/` - Create new grocery category
- `GET /grocery-categories/{id}` - Get specific grocery category
- `PUT /grocery-categories/{id}` - Update grocery category
- `DELETE /grocery-categories/{id}` - Delete grocery category
### Products
- `GET /products/` - List all products
- `POST /products/` - Create new product
- `GET /products/{id}` - Get specific product
- `PUT /products/{id}` - Update product
- `DELETE /products/{id}` - Delete product
### Shops ### Shops
- `GET /shops/` - List all shops - `GET /shops/` - List all shops
- `POST /shops/` - Create new shop - `POST /shops/` - Create new shop
- `GET /shops/{id}` - Get specific shop - `GET /shops/{id}` - Get specific shop
- `PUT /shops/{id}` - Update shop
- `DELETE /shops/{id}` - Delete shop
### Brands in Shops
- `GET /brands-in-shops/` - List all brand-shop associations
- `POST /brands-in-shops/` - Create new brand-shop association
- `GET /brands-in-shops/{id}` - Get specific brand-shop association
- `GET /brands-in-shops/shop/{shop_id}` - Get brands available in specific shop
- `GET /brands-in-shops/brand/{brand_id}` - Get shops that carry specific brand
- `DELETE /brands-in-shops/{id}` - Delete brand-shop association
### Related Products
- `GET /related-products/` - List all product relationships
- `POST /related-products/` - Create new product relationship
- `GET /related-products/{id}` - Get specific product relationship
- `GET /related-products/product/{product_id}` - Get all products related to a specific product
- `PUT /related-products/{id}` - Update relationship type
- `DELETE /related-products/{id}` - Delete product relationship
### Shopping Events ### Shopping Events
- `GET /shopping-events/` - List all shopping events - `GET /shopping-events/` - List all shopping events
- `POST /shopping-events/` - Create new shopping event - `POST /shopping-events/` - Create new shopping event
- `GET /shopping-events/{id}` - Get specific shopping event - `GET /shopping-events/{id}` - Get specific shopping event
- `PUT /shopping-events/{id}` - Update shopping event
- `DELETE /shopping-events/{id}` - Delete shopping event
### Statistics ### Statistics
- `GET /stats/categories` - Category spending statistics - `GET /stats/categories` - Category spending statistics
@@ -158,11 +381,51 @@ A web application for tracking grocery prices and shopping events. Built with Fa
## 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 Categories**: Create grocery categories (e.g., "Dairy", "Produce", "Meat")
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events 3. **Add Brands**: Create brands for your products (optional)
4. **Track Prices**: Monitor how prices change over time 4. **Configure Shop-Brand Availability**: Associate brands with shops where they're available
5. **View Statistics**: Analyze spending patterns by category and shop 5. **Add Products**: Create product items linked directly to categories and optionally to brands
6. **Link Related Products**: Connect products that are related (e.g., same item in different sizes, brand alternatives)
7. **Record Purchases**: Use the "Add Shopping Event" form to record purchases with multiple products
8. **Track Prices**: Monitor how prices change over time for the same products
9. **Import/Export Data**: Use CSV files to bulk import or export your data
10. **View Statistics**: Analyze spending patterns by category and shop
## Deployment
### Docker Deployment (Recommended)
The application includes a complete Docker Compose setup for easy deployment. This is the recommended way to deploy the application in production.
**Quick deployment:**
```bash
# Clone repository
git clone <your-repo-url>
cd groceries
# Setup environment
cp docker.env.example .env
# Edit .env with your production values
# Deploy
docker-compose up -d
# Initialize database
docker-compose exec backend alembic upgrade head
```
**Services included:**
- PostgreSQL database with persistent storage
- FastAPI backend with health checks
- React frontend served by Nginx
- Automatic service restart and dependency management
For comprehensive deployment instructions, troubleshooting, and production considerations, see **[DOCKER_DEPLOYMENT.md](DOCKER_DEPLOYMENT.md)**.
### Manual Deployment
For development or custom deployments, you can also run the services manually using the [Development Setup](#development-setup) instructions above.
## Development ## Development
@@ -188,44 +451,6 @@ alembic revision --autogenerate -m "Description"
alembic upgrade head alembic upgrade head
``` ```
## Docker Deployment
Create `docker-compose.yml` for easy deployment:
```yaml
version: '3.8'
services:
db:
image: postgres:15
environment:
POSTGRES_DB: grocery_tracker
POSTGRES_USER: grocery_user
POSTGRES_PASSWORD: your_password
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
backend:
build: ./backend
ports:
- "8000:8000"
depends_on:
- db
environment:
DATABASE_URL: postgresql://grocery_user:your_password@db:5432/grocery_tracker
frontend:
build: ./frontend
ports:
- "3000:3000"
depends_on:
- backend
volumes:
postgres_data:
```
## Contributing ## Contributing
1. Fork the repository 1. Fork the repository

View File

@@ -0,0 +1,271 @@
# Soft Delete Implementation with Historical Tracking
## Overview
This implementation extends the existing temporal tables system to support soft deletes with proper historical tracking. When a product is "deleted", it's not actually removed from the database but marked as deleted with a timestamp, while maintaining full historical data.
## Key Features
### 1. Soft Delete Mechanism
- Products are marked as `deleted = true` instead of being physically removed
- Deletion creates a historical record of the product's state before deletion
- Deleted products get a new `valid_from` date set to the deletion date
- All historical versions remain intact for audit purposes
### 2. UI Enhancements
- **Product List**: "Show deleted" toggle next to "Add New Product" button
- **Visual Indicators**: Deleted products shown with:
- Red background tint and reduced opacity
- Strikethrough text
- 🗑️ emoji indicator
- Disabled edit/duplicate/delete actions
- **Shopping Events**: Products deleted before/on shopping date are automatically filtered out
### 3. API Behavior
- **Default**: Deleted products are hidden from all product listings
- **Optional**: `show_deleted=true` parameter shows all products including deleted ones
- **Shopping Events**: New endpoint `/products/available-for-shopping/{date}` filters products based on deletion status at specific date
## Database Schema Changes
### Products Table
```sql
ALTER TABLE products
ADD COLUMN deleted BOOLEAN DEFAULT FALSE NOT NULL;
```
### Products History Table
```sql
-- Already included in products_history table
deleted BOOLEAN DEFAULT FALSE NOT NULL
```
### Updated Trigger
The `products_versioning_trigger` now **consistently handles ALL historization**:
- **UPDATE operations**: Creates history records for both automatic and manual versioning
- **DELETE operations**: Creates history records when products are deleted
- **Smart versioning**: Automatically detects manual vs automatic versioning based on `valid_from` changes
- **Centralized logic**: All temporal logic is in the database trigger, not split between trigger and application
### Trigger Benefits
1. **Consistency**: All versioning operations follow the same pattern
2. **Reliability**: Database-level enforcement prevents inconsistencies
3. **Simplicity**: Application code just sets fields, trigger handles the rest
4. **Performance**: Single database operation handles both data update and history creation
## API Endpoints
### Modified Endpoints
#### `GET /products/`
- **New Parameter**: `show_deleted: bool = False`
- **Behavior**: Filters out deleted products by default
- **Usage**: `GET /products/?show_deleted=true` to include deleted products
#### `PUT /products/{id}` & `DELETE /products/{id}`
- **Enhanced**: Now properly handles soft delete with historical tracking
- **Validation**: Prevents operations on already-deleted products
### New Endpoints
#### `GET /products/available-for-shopping/{shopping_date}`
- **Purpose**: Get products that were available (not deleted) on a specific shopping date
- **Logic**: Returns products where:
- `deleted = false` (never deleted), OR
- `deleted = true` AND `valid_from > shopping_date` (deleted after shopping date)
- **Usage**: Used by shopping event modals to filter product lists
## Frontend Implementation
### ProductList Component
```typescript
// New state for toggle
const [showDeleted, setShowDeleted] = useState(false);
// Updated API call
const response = await productApi.getAll(showDeleted);
// Visual styling for deleted products
className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}
```
### AddShoppingEventModal Component
```typescript
// Dynamic product fetching based on shopping date
const response = formData.date
? await productApi.getAvailableForShopping(formData.date)
: await productApi.getAll(false);
// Refetch products when date changes
useEffect(() => {
if (isOpen && formData.date) {
fetchProducts();
}
}, [formData.date, isOpen]);
```
## Deletion Process Flow
### 1. User Initiates Delete
- User clicks "Delete" button on a product
- Confirmation modal appears
### 2. Backend Processing
```python
# Simple application code - trigger handles the complexity
product.deleted = True
product.valid_from = date.today() # Manual versioning date
product.updated_at = func.now()
# Trigger automatically:
# 1. Detects the change (deleted field + valid_from change)
# 2. Creates history record with old data (deleted=False, valid_to=today)
# 3. Ensures new record has valid_to='9999-12-31'
```
**Trigger Logic (Automatic)**:
- Detects manual versioning because `valid_from` changed
- Uses the new `valid_from` as the cutoff date for history
- Creates history record: `{...old_data, valid_to: new_valid_from, operation: 'U'}`
- No additional application logic needed
### 3. Frontend Updates
- Product list refreshes
- Deleted product appears with visual indicators (if "Show deleted" is enabled)
- Product becomes unavailable for new shopping events
## Historical Data Integrity
### Shopping Events
- **Guarantee**: Shopping events always show products exactly as they existed when purchased
- **Implementation**: Uses `/products/{id}/at/{date}` endpoint to fetch historical product state
- **Benefit**: Even if a product is deleted later, historical shopping events remain accurate
### Audit Trail
- **Complete History**: All product versions are preserved in `products_history`
- **Deletion Tracking**: History records show when and why products were deleted
- **Temporal Queries**: Can reconstruct product state at any point in time
## Usage Examples
### 1. View All Products (Default)
```bash
GET /products/
# Returns only non-deleted products
```
### 2. View All Products Including Deleted
```bash
GET /products/?show_deleted=true
# Returns all products with deleted status
```
### 3. Get Products Available for Shopping on Specific Date
```bash
GET /products/available-for-shopping/2024-01-15
# Returns products that were not deleted on 2024-01-15
```
### 4. View Historical Shopping Event
```bash
GET /shopping-events/123/products-as-purchased
# Returns products exactly as they were when purchased, regardless of current deletion status
```
## Benefits
1. **Data Preservation**: No data is ever lost
2. **Audit Compliance**: Complete audit trail of all changes
3. **Historical Accuracy**: Shopping events remain accurate over time
4. **User Experience**: Clean interface with optional deleted product visibility
5. **Flexibility**: Easy to "undelete" products if needed (future enhancement)
## Future Enhancements
1. **Undelete Functionality**: Add ability to restore deleted products
2. **Bulk Operations**: Delete/restore multiple products at once
3. **Deletion Reasons**: Add optional reason field for deletions
4. **Advanced Filtering**: Filter by deletion date, reason, etc.
5. **Reporting**: Generate reports on deleted products and their impact
## Migration Instructions
1. **Run Migration**: Execute `temporal_migration.sql` to add `deleted` column
2. **Deploy Backend**: Update backend with new API endpoints and logic
3. **Deploy Frontend**: Update frontend with new UI components and API calls
4. **Test**: Verify soft delete functionality and historical data integrity
## Testing Scenarios
1. **Basic Deletion**: Delete a product and verify it's hidden from default view
2. **Show Deleted Toggle**: Enable "Show deleted" and verify deleted products appear with proper styling
3. **Shopping Event Filtering**: Create shopping event and verify deleted products don't appear in product list
4. **Historical Accuracy**: Delete a product that was in a past shopping event, verify the shopping event still shows correct historical data
5. **Date-based Filtering**: Test `/products/available-for-shopping/{date}` with various dates before/after product deletions
## Database Initialization
### ⚠️ Important: Trigger Creation
The temporal triggers are essential for the soft delete functionality. They must be created in **all environments**:
### **Method 1: Automatic (Recommended)**
The triggers are now automatically created when using SQLAlchemy's `create_all()`:
```python
# This now creates both tables AND triggers
models.Base.metadata.create_all(bind=engine)
```
**How it works**:
- SQLAlchemy event listener detects when `ProductHistory` table is created
- Automatically executes trigger creation SQL
- Works for fresh dev, test, and production databases
### **Method 2: Manual Database Script**
For explicit control, use the initialization script:
```bash
# Run this for fresh database setup
python backend/database_init.py
```
**Features**:
- Creates all tables
- Creates all triggers
- Checks for existing triggers (safe to run multiple times)
- Provides detailed feedback
### **Method 3: Migration File**
For existing databases, run the migration:
```sql
-- Execute temporal_migration.sql
\i temporal_migration.sql
```
## Environment Setup Guide
### **Development (Fresh DB)**
```bash
# Option A: Automatic (when starting app)
python backend/main.py
# ✅ Tables + triggers created automatically
# Option B: Explicit setup
python backend/database_init.py
python backend/main.py
```
### **Production (Fresh DB)**
```bash
# Recommended: Explicit initialization
python backend/database_init.py
# Then start the application
```
### **Existing Database (Migration)**
```bash
# Apply migration to add soft delete functionality
docker-compose exec db psql -U postgres -d groceries -f /tmp/temporal_migration.sql
```

111
TEMPORAL_FEATURES.md Normal file
View File

@@ -0,0 +1,111 @@
# Temporal Product Tracking Features
This document describes the new historical product tracking functionality that allows you to track product changes over time.
## Features
### 1. Historical Product Versioning
- Products now maintain a complete history of changes
- When product attributes (name, weight, category, etc.) are updated, the old version is automatically saved
- Each version has `valid_from` and `valid_to` dates indicating when it was active
### 2. Manual Effective Dates
- When creating or editing products, you can specify a custom "Effective From" date
- If not specified, the current date is used
- This allows you to retroactively record product changes or schedule future changes
### 3. Shopping Event Historical Accuracy
- Shopping events now show products exactly as they were when purchased
- Even if a product's weight or name has changed since purchase, the historical data is preserved
## Database Changes
### New Tables
- `products_history` - Stores old versions of products when they're updated
### New Columns
- `products.valid_from` (DATE) - When this product version became effective
- `products.valid_to` (DATE) - When this product version was superseded (9999-12-31 for current versions)
## API Endpoints
### New Endpoints
- `GET /current-date` - Get current date for form prefilling
- `GET /products/{id}/history` - Get all historical versions of a product
- `GET /products/{id}/at/{date}` - Get product as it existed on a specific date (YYYY-MM-DD)
- `GET /shopping-events/{id}/products-as-purchased` - Get products as they were when purchased
### Updated Endpoints
- `POST /products/` - Now accepts optional `valid_from` field
- `PUT /products/{id}` - Now accepts optional `valid_from` field for manual versioning
## Frontend Changes
### Product Forms
- Added "Effective From" date field to product create/edit forms
- Date field is pre-filled with current date
- Required field with helpful description
### API Integration
- ProductCreate interface now includes optional `valid_from` field
- New utilityApi for fetching current date
- Proper form validation and error handling
## Migration
Run `temporal_migration.sql` to:
1. Add temporal columns to existing products table
2. Create products_history table
3. Set up automatic versioning trigger
4. Initialize existing products with baseline date (2025-05-01)
## Usage Examples
### Creating a Product with Custom Date
```json
POST /products/
{
"name": "Organic Milk",
"category_id": 1,
"weight": 1000,
"weight_unit": "ml",
"valid_from": "2025-03-15"
}
```
### Updating a Product with Future Effective Date
```json
PUT /products/123
{
"weight": 1200,
"valid_from": "2025-04-01"
}
```
### Getting Historical Product Data
```bash
# Get product as it was on January 15, 2025
GET /products/123/at/2025-01-15
# Get all versions of a product
GET /products/123/history
# Get shopping event with historical product data
GET /shopping-events/456/products-as-purchased
```
## Benefits
1. **Complete Audit Trail** - Never lose track of how products have changed over time
2. **Accurate Historical Data** - Shopping events always show correct product information
3. **Flexible Dating** - Record changes retroactively or schedule future changes
4. **Automatic Versioning** - No manual effort required for basic updates
5. **Cross-Database Compatibility** - Uses standard DATE fields that work with PostgreSQL, SQLite, MySQL, etc.
## Technical Notes
- Versioning is handled automatically by database triggers
- Manual versioning is used when custom `valid_from` dates are specified
- Date format: YYYY-MM-DD (ISO 8601)
- Far future date (9999-12-31) represents current/active versions
- Temporal fields are not displayed in regular product lists (by design)

28
backend/Dockerfile Normal file
View File

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

View File

@@ -11,7 +11,7 @@ DATABASE_URL = os.getenv("DATABASE_URL")
if not DATABASE_URL: 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")

88
backend/database_init.py Normal file
View File

@@ -0,0 +1,88 @@
#!/usr/bin/env python3
"""
Database initialization script that creates all tables and triggers.
Use this for setting up fresh development or production databases.
Usage:
python database_init.py
"""
import sys
import os
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# Add parent directory to path to import models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from models import Base
from database import get_database_url
def init_database():
"""Initialize database with all tables and triggers"""
database_url = get_database_url()
engine = create_engine(database_url)
print("🚀 Initializing database...")
print(f"📍 Database URL: {database_url}")
try:
# Create all tables first
print("📊 Creating tables...")
Base.metadata.create_all(bind=engine)
print("✅ Tables created successfully")
# Verify critical tables exist before creating triggers
print("🔍 Verifying tables exist...")
with engine.connect() as connection:
# Check if products and products_history tables exist
products_exists = connection.execute(text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'products'
);
""")).scalar()
history_exists = connection.execute(text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'products_history'
);
""")).scalar()
if not products_exists:
raise Exception("Products table was not created")
if not history_exists:
raise Exception("Products history table was not created")
print("✅ Required tables verified")
# Create triggers (if not already created by event listener)
print("⚙️ Ensuring triggers are created...")
with engine.connect() as connection:
# Check if trigger exists
result = connection.execute(text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.triggers
WHERE trigger_name = 'products_versioning_trigger'
);
""")).scalar()
if not result:
print("📝 Creating products versioning trigger...")
from models import PRODUCTS_VERSIONING_TRIGGER_SQL
connection.execute(text(PRODUCTS_VERSIONING_TRIGGER_SQL))
connection.commit()
print("✅ Trigger created successfully")
else:
print("✅ Trigger already exists")
print("🎉 Database initialization completed successfully!")
except Exception as e:
print(f"❌ Error initializing database: {e}")
sys.exit(1)
if __name__ == "__main__":
init_database()

View File

@@ -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

View File

@@ -1,17 +1,19 @@
from fastapi import FastAPI, Depends, HTTPException, status from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from sqlalchemy import text, func
from typing import List from typing import List
import models, schemas import models, schemas
from database import engine, get_db from database import engine, get_db
from version import __version__, __app_name__, __description__
# Create database tables # Create database tables
models.Base.metadata.create_all(bind=engine) models.Base.metadata.create_all(bind=engine)
app = FastAPI( app = FastAPI(
title="Grocery Tracker API", title=__app_name__,
description="API for tracking grocery prices and shopping events", description=__description__,
version="1.0.0" version=__version__
) )
# CORS middleware for React frontend # CORS middleware for React frontend
@@ -23,55 +25,372 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
"""Build a shopping event response with products from the association table"""
# Get products with their event-specific data including category and brand information
product_data = db.execute(
text("""
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
sep.amount, sep.price, sep.discount,
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 grocery_categories gc ON p.category_id = gc.id
LEFT JOIN brands b ON p.brand_id = b.id
WHERE sep.shopping_event_id = :event_id
"""),
{"event_id": event.id}
).fetchall()
# Convert to ProductWithEventData objects
products_with_data = []
for row in product_data:
category = schemas.GroceryCategory(
id=row.category_id,
name=row.category_name,
created_at=row.category_created_at,
updated_at=row.category_updated_at
)
brand = None
if row.brand_id is not None:
brand = schemas.Brand(
id=row.brand_id,
name=row.brand_name,
created_at=row.brand_created_at,
updated_at=row.brand_updated_at
)
products_with_data.append(
schemas.ProductWithEventData(
id=row.id,
name=row.name,
category=category,
brand=brand,
organic=row.organic,
weight=row.weight,
weight_unit=row.weight_unit,
amount=row.amount,
price=row.price,
discount=row.discount
)
)
return schemas.ShoppingEventResponse(
id=event.id,
shop_id=event.shop_id,
date=event.date,
total_amount=event.total_amount,
notes=event.notes,
created_at=event.created_at,
shop=event.shop,
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": __app_name__, "version": __version__, "name": "Groceries Tracker Backend"}
# Grocery endpoints # Utility endpoints
@app.post("/groceries/", response_model=schemas.Grocery) @app.get("/current-date")
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)): def get_current_date():
db_grocery = models.Grocery(**grocery.dict()) """Get current date for use as default in valid_from fields"""
db.add(db_grocery) from datetime import date
return {"current_date": date.today().isoformat()}
@app.get("/products/available-for-shopping/{shopping_date}", response_model=List[schemas.Product])
def get_products_available_for_shopping(shopping_date: str, db: Session = Depends(get_db)):
"""Get products that were available (not deleted) on a specific shopping date"""
from datetime import datetime
try:
# Parse the shopping date
target_date = datetime.strptime(shopping_date, '%Y-%m-%d').date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD format")
# Get products that were either:
# 1. Never deleted (deleted=False)
# 2. Deleted after the shopping date (valid_from > shopping_date for deleted=True products)
products = db.query(models.Product).filter(
(models.Product.deleted == False) |
((models.Product.deleted == True) & (models.Product.valid_from > target_date))
).all()
return products
# Product endpoints
@app.post("/products/", response_model=schemas.Product)
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
# Validate category exists
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == product.category_id).first()
if category is None:
raise HTTPException(status_code=404, detail="Category 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")
# Validate valid_from date if provided
if product.valid_from is not None:
from datetime import date
if product.valid_from > date.today():
raise HTTPException(status_code=400, detail="Valid from date cannot be in the future")
# Create product data
product_data = product.dict(exclude={'valid_from'})
db_product = models.Product(**product_data)
# Set valid_from if provided, otherwise let database default handle it
if product.valid_from is not None:
db_product.valid_from = product.valid_from
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, show_deleted: bool = False, db: Session = Depends(get_db)):
groceries = db.query(models.Grocery).offset(skip).limit(limit).all() query = db.query(models.Product)
return groceries if not show_deleted:
query = query.filter(models.Product.deleted == False)
products = query.offset(skip).limit(limit).all()
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.get("/products/{product_id}/valid-from")
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)): def get_product_valid_from(product_id: int, db: Session = Depends(get_db)):
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first() """Get the current valid_from date for a product (used for validation when editing)"""
if grocery is None: product = db.query(models.Product).filter(models.Product.id == product_id).first()
raise HTTPException(status_code=404, detail="Grocery not found") if product is None:
raise HTTPException(status_code=404, detail="Product not found")
return {"valid_from": product.valid_from.isoformat()}
update_data = grocery_update.dict(exclude_unset=True) @app.put("/products/{product_id}", response_model=schemas.Product)
def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
product = db.query(models.Product).filter(models.Product.id == product_id).first()
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
update_data = product_update.dict(exclude_unset=True, exclude={'valid_from'})
# 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="Category 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")
# Validate valid_from date if provided
if product_update.valid_from is not None:
from datetime import date
if product_update.valid_from > date.today():
raise HTTPException(status_code=400, detail="Valid from date cannot be in the future")
if product_update.valid_from <= product.valid_from:
raise HTTPException(
status_code=400,
detail=f"Valid from date must be after the current product's valid from date ({product.valid_from})"
)
# Check if any versioned fields are actually changing
versioned_fields = ['name', 'category_id', 'brand_id', 'organic', 'weight', 'weight_unit']
has_changes = any(
field in update_data and getattr(product, field) != update_data[field]
for field in versioned_fields
)
# Apply the updates - trigger will handle history creation automatically
for field, value in update_data.items(): for field, value in update_data.items():
setattr(grocery, field, value) setattr(product, field, value)
# Set valid_from if provided for manual versioning
if product_update.valid_from is not None:
product.valid_from = product_update.valid_from
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")
if product.deleted:
raise HTTPException(status_code=400, detail="Product is already deleted")
from datetime import date
# Simply mark as deleted and set valid_from to today
# The trigger will automatically create the history record
product.deleted = True
product.valid_from = date.today()
product.updated_at = func.now()
db.delete(grocery)
db.commit() db.commit()
return {"message": "Grocery deleted successfully"} return {"message": "Product deleted successfully"}
# Historical Product endpoints
@app.get("/products/{product_id}/history", response_model=List[schemas.ProductHistory])
def get_product_history(product_id: int, db: Session = Depends(get_db)):
"""Get all historical versions of a product"""
# Check if product exists
product = db.query(models.Product).filter(models.Product.id == product_id).first()
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
# Get history from history table
history = db.query(models.ProductHistory).filter(
models.ProductHistory.id == product_id
).order_by(models.ProductHistory.valid_from.desc()).all()
return history
@app.get("/products/{product_id}/at/{date}", response_model=schemas.ProductAtDate)
def get_product_at_date(product_id: int, date: str, db: Session = Depends(get_db)):
"""Get product as it existed at a specific date - CRUCIAL for shopping events"""
from datetime import datetime, date as date_type
try:
# Parse the date string (accept YYYY-MM-DD format)
target_date = datetime.strptime(date, '%Y-%m-%d').date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD format")
# First try current products table
current_product = db.query(models.Product).filter(
models.Product.id == product_id,
models.Product.valid_from <= target_date,
models.Product.valid_to >= target_date
).first()
if current_product:
# Get related data
category = db.query(models.GroceryCategory).filter(
models.GroceryCategory.id == current_product.category_id
).first()
brand = None
if current_product.brand_id:
brand = db.query(models.Brand).filter(
models.Brand.id == current_product.brand_id
).first()
return schemas.ProductAtDate(
id=current_product.id,
name=current_product.name,
category_id=current_product.category_id,
category=category,
brand_id=current_product.brand_id,
brand=brand,
organic=current_product.organic,
weight=current_product.weight,
weight_unit=current_product.weight_unit,
valid_from=current_product.valid_from,
valid_to=current_product.valid_to,
deleted=current_product.deleted,
was_current=True
)
# Try history table
historical_product = db.query(models.ProductHistory).filter(
models.ProductHistory.id == product_id,
models.ProductHistory.valid_from <= target_date,
models.ProductHistory.valid_to >= target_date
).first()
if historical_product:
# Get related data (note: these might have changed too, but we'll use current versions)
category = db.query(models.GroceryCategory).filter(
models.GroceryCategory.id == historical_product.category_id
).first()
brand = None
if historical_product.brand_id:
brand = db.query(models.Brand).filter(
models.Brand.id == historical_product.brand_id
).first()
return schemas.ProductAtDate(
id=historical_product.id,
name=historical_product.name,
category_id=historical_product.category_id,
category=category,
brand_id=historical_product.brand_id,
brand=brand,
organic=historical_product.organic,
weight=historical_product.weight,
weight_unit=historical_product.weight_unit,
valid_from=historical_product.valid_from,
valid_to=historical_product.valid_to,
deleted=historical_product.deleted,
was_current=False
)
# Product didn't exist at that date
raise HTTPException(
status_code=404,
detail=f"Product {product_id} did not exist on {date}"
)
@app.get("/shopping-events/{event_id}/products-as-purchased", response_model=List[schemas.ProductAtPurchase])
def get_shopping_event_products_as_purchased(event_id: int, db: Session = Depends(get_db)):
"""Get products as they were when purchased - shows historical product data"""
# Get the shopping event
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
if event is None:
raise HTTPException(status_code=404, detail="Shopping event not found")
# Get products from association table
products_data = db.execute(
text("""
SELECT sep.product_id, sep.amount, sep.price, sep.discount
FROM shopping_event_products sep
WHERE sep.shopping_event_id = :event_id
"""),
{"event_id": event_id}
).fetchall()
result = []
for product_data in products_data:
# Get product as it was at the time of purchase
try:
# Extract just the date from the shopping event datetime
purchase_date = event.date.date().strftime('%Y-%m-%d')
product_at_purchase = get_product_at_date(
product_data.product_id,
purchase_date,
db
)
result.append(schemas.ProductAtPurchase(
product=product_at_purchase,
amount=product_data.amount,
price=product_data.price,
discount=product_data.discount
))
except HTTPException:
# Product didn't exist at purchase time (shouldn't happen, but handle gracefully)
continue
return result
# Shop endpoints # Shop endpoints
@app.post("/shops/", response_model=schemas.Shop) @app.post("/shops/", response_model=schemas.Shop)
@@ -94,6 +413,205 @@ def read_shop(shop_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Shop not found") raise HTTPException(status_code=404, detail="Shop not found")
return shop return shop
@app.put("/shops/{shop_id}", response_model=schemas.Shop)
def update_shop(shop_id: int, shop_update: schemas.ShopUpdate, db: Session = Depends(get_db)):
shop = db.query(models.Shop).filter(models.Shop.id == shop_id).first()
if shop is None:
raise HTTPException(status_code=404, detail="Shop not found")
update_data = shop_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(shop, field, value)
db.commit()
db.refresh(shop)
return shop
@app.delete("/shops/{shop_id}")
def delete_shop(shop_id: int, db: Session = Depends(get_db)):
shop = db.query(models.Shop).filter(models.Shop.id == shop_id).first()
if shop is None:
raise HTTPException(status_code=404, detail="Shop not found")
db.delete(shop)
db.commit()
return {"message": "Shop deleted successfully"}
# Brand endpoints
@app.post("/brands/", response_model=schemas.Brand)
def create_brand(brand: schemas.BrandCreate, db: Session = Depends(get_db)):
db_brand = models.Brand(**brand.dict())
db.add(db_brand)
db.commit()
db.refresh(db_brand)
return db_brand
@app.get("/brands/", response_model=List[schemas.Brand])
def read_brands(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
brands = db.query(models.Brand).offset(skip).limit(limit).all()
return brands
@app.get("/brands/{brand_id}", response_model=schemas.Brand)
def read_brand(brand_id: int, db: Session = Depends(get_db)):
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
return brand
@app.put("/brands/{brand_id}", response_model=schemas.Brand)
def update_brand(brand_id: int, brand_update: schemas.BrandUpdate, db: Session = Depends(get_db)):
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
update_data = brand_update.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(brand, field, value)
db.commit()
db.refresh(brand)
return brand
@app.delete("/brands/{brand_id}")
def delete_brand(brand_id: int, db: Session = Depends(get_db)):
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
# Check if any products reference this brand
products_with_brand = db.query(models.Product).filter(models.Product.brand_id == brand_id).first()
if products_with_brand:
raise HTTPException(
status_code=400,
detail="Cannot delete brand: products are still associated with this brand"
)
db.delete(brand)
db.commit()
return {"message": "Brand deleted successfully"}
# BrandInShop endpoints
@app.post("/brands-in-shops/", response_model=schemas.BrandInShop)
def create_brand_in_shop(brand_in_shop: schemas.BrandInShopCreate, db: Session = Depends(get_db)):
# Validate shop exists
shop = db.query(models.Shop).filter(models.Shop.id == brand_in_shop.shop_id).first()
if shop is None:
raise HTTPException(status_code=404, detail="Shop not found")
# Validate brand exists
brand = db.query(models.Brand).filter(models.Brand.id == brand_in_shop.brand_id).first()
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
# Check if this combination already exists
existing = db.query(models.BrandInShop).filter(
models.BrandInShop.shop_id == brand_in_shop.shop_id,
models.BrandInShop.brand_id == brand_in_shop.brand_id
).first()
if existing:
raise HTTPException(status_code=400, detail="This brand is already associated with this shop")
db_brand_in_shop = models.BrandInShop(**brand_in_shop.dict())
db.add(db_brand_in_shop)
db.commit()
db.refresh(db_brand_in_shop)
return db_brand_in_shop
@app.get("/brands-in-shops/", response_model=List[schemas.BrandInShop])
def read_brands_in_shops(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
brands_in_shops = db.query(models.BrandInShop).offset(skip).limit(limit).all()
return brands_in_shops
@app.get("/brands-in-shops/shop/{shop_id}", response_model=List[schemas.BrandInShop])
def read_brands_in_shop(shop_id: int, db: Session = Depends(get_db)):
# Validate shop exists
shop = db.query(models.Shop).filter(models.Shop.id == shop_id).first()
if shop is None:
raise HTTPException(status_code=404, detail="Shop not found")
brands_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.shop_id == shop_id).all()
return brands_in_shop
@app.get("/brands-in-shops/brand/{brand_id}", response_model=List[schemas.BrandInShop])
def read_shops_with_brand(brand_id: int, db: Session = Depends(get_db)):
# Validate brand exists
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")
shops_with_brand = db.query(models.BrandInShop).filter(models.BrandInShop.brand_id == brand_id).all()
return shops_with_brand
@app.get("/brands-in-shops/{brand_in_shop_id}", response_model=schemas.BrandInShop)
def read_brand_in_shop(brand_in_shop_id: int, db: Session = Depends(get_db)):
brand_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.id == brand_in_shop_id).first()
if brand_in_shop is None:
raise HTTPException(status_code=404, detail="Brand in shop association not found")
return brand_in_shop
@app.delete("/brands-in-shops/{brand_in_shop_id}")
def delete_brand_in_shop(brand_in_shop_id: int, db: Session = Depends(get_db)):
brand_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.id == brand_in_shop_id).first()
if brand_in_shop is None:
raise HTTPException(status_code=404, detail="Brand in shop association not found")
db.delete(brand_in_shop)
db.commit()
return {"message": "Brand in shop association 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 products reference this category
products_with_category = db.query(models.Product).filter(models.Product.category_id == category_id).first()
if products_with_category:
raise HTTPException(
status_code=400,
detail="Cannot delete category: products are still associated with this category"
)
db.delete(category)
db.commit()
return {"message": "Grocery category 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)):
@@ -113,36 +631,102 @@ 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=product_item.price,
discount=product_item.discount
) )
) )
db.commit() db.commit()
db.refresh(db_event) db.refresh(db_event)
return db_event return build_shopping_event_response(db_event, db)
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse]) @app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
events = db.query(models.ShoppingEvent).offset(skip).limit(limit).all() events = db.query(models.ShoppingEvent).order_by(models.ShoppingEvent.created_at.desc()).offset(skip).limit(limit).all()
return events return [build_shopping_event_response(event, db) for event in events]
@app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse) @app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
def read_shopping_event(event_id: int, db: Session = Depends(get_db)): def read_shopping_event(event_id: int, db: Session = Depends(get_db)):
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first() event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
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")
return event return build_shopping_event_response(event, db)
@app.put("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
def update_shopping_event(event_id: int, event_update: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
# Get the existing event
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
if event is None:
raise HTTPException(status_code=404, detail="Shopping event not found")
# Verify shop exists
shop = db.query(models.Shop).filter(models.Shop.id == event_update.shop_id).first()
if shop is None:
raise HTTPException(status_code=404, detail="Shop not found")
# Update the shopping event
event.shop_id = event_update.shop_id
event.date = event_update.date
event.total_amount = event_update.total_amount
event.notes = event_update.notes
# Remove existing product associations
db.execute(
models.shopping_event_products.delete().where(
models.shopping_event_products.c.shopping_event_id == event_id
)
)
# Add new product associations
for product_item in event_update.products:
product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
if product is None:
raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
# Insert into association table
db.execute(
models.shopping_event_products.insert().values(
shopping_event_id=event_id,
product_id=product_item.product_id,
amount=product_item.amount,
price=product_item.price,
discount=product_item.discount
)
)
db.commit()
db.refresh(event)
return build_shopping_event_response(event, db)
@app.delete("/shopping-events/{event_id}")
def delete_shopping_event(event_id: int, db: Session = Depends(get_db)):
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
if event is None:
raise HTTPException(status_code=404, detail="Shopping event not found")
# Delete product associations first
db.execute(
models.shopping_event_products.delete().where(
models.shopping_event_products.c.shopping_event_id == event_id
)
)
# Delete the shopping event
db.delete(event)
db.commit()
return {"message": "Shopping event deleted successfully"}
# Statistics endpoints # Statistics endpoints
@app.get("/stats/categories", response_model=List[schemas.CategoryStats]) @app.get("/stats/categories", response_model=List[schemas.CategoryStats])
@@ -155,6 +739,171 @@ def get_shop_stats(db: Session = Depends(get_db)):
# This would need more complex SQL query - placeholder for now # This would need more complex SQL query - placeholder for now
return [] return []
# Related Products endpoints
@app.post("/related-products/", response_model=schemas.RelatedProduct)
def create_related_product(related_product: schemas.RelatedProductCreate, db: Session = Depends(get_db)):
# Validate both products exist
product = db.query(models.Product).filter(models.Product.id == related_product.product_id).first()
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
related = db.query(models.Product).filter(models.Product.id == related_product.related_product_id).first()
if related is None:
raise HTTPException(status_code=404, detail="Related product not found")
# Prevent self-referencing
if related_product.product_id == related_product.related_product_id:
raise HTTPException(status_code=400, detail="A product cannot be related to itself")
# Check if relationship already exists (in either direction)
existing = db.execute(
models.related_products.select().where(
((models.related_products.c.product_id == related_product.product_id) &
(models.related_products.c.related_product_id == related_product.related_product_id)) |
((models.related_products.c.product_id == related_product.related_product_id) &
(models.related_products.c.related_product_id == related_product.product_id))
)
).first()
if existing:
raise HTTPException(status_code=400, detail="Products are already related")
# Insert the relationship
result = db.execute(
models.related_products.insert().values(
product_id=related_product.product_id,
related_product_id=related_product.related_product_id,
relationship_type=related_product.relationship_type
)
)
db.commit()
# Get the created relationship
relationship_id = result.inserted_primary_key[0]
created_relationship = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
return schemas.RelatedProduct(
id=created_relationship.id,
product_id=created_relationship.product_id,
related_product_id=created_relationship.related_product_id,
relationship_type=created_relationship.relationship_type,
created_at=created_relationship.created_at
)
@app.get("/related-products/", response_model=List[schemas.RelatedProduct])
def read_related_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
relationships = db.execute(
models.related_products.select().offset(skip).limit(limit)
).fetchall()
return [
schemas.RelatedProduct(
id=rel.id,
product_id=rel.product_id,
related_product_id=rel.related_product_id,
relationship_type=rel.relationship_type,
created_at=rel.created_at
)
for rel in relationships
]
@app.get("/related-products/product/{product_id}", response_model=List[schemas.Product])
def get_related_products_for_product(product_id: int, db: Session = Depends(get_db)):
# Validate product exists
product = db.query(models.Product).filter(models.Product.id == product_id).first()
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
# Get related products (bidirectional)
related_product_ids = db.execute(
text("""
SELECT CASE
WHEN product_id = :product_id THEN related_product_id
ELSE product_id
END as related_id
FROM related_products
WHERE product_id = :product_id OR related_product_id = :product_id
"""),
{"product_id": product_id}
).fetchall()
if not related_product_ids:
return []
# Get the actual product objects
related_ids = [row.related_id for row in related_product_ids]
related_products = db.query(models.Product).filter(models.Product.id.in_(related_ids)).all()
return related_products
@app.get("/related-products/{relationship_id}", response_model=schemas.RelatedProduct)
def read_related_product(relationship_id: int, db: Session = Depends(get_db)):
relationship = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
if relationship is None:
raise HTTPException(status_code=404, detail="Related product relationship not found")
return schemas.RelatedProduct(
id=relationship.id,
product_id=relationship.product_id,
related_product_id=relationship.related_product_id,
relationship_type=relationship.relationship_type,
created_at=relationship.created_at
)
@app.put("/related-products/{relationship_id}", response_model=schemas.RelatedProduct)
def update_related_product(relationship_id: int, update_data: schemas.RelatedProductUpdate, db: Session = Depends(get_db)):
# Check if relationship exists
existing = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
if existing is None:
raise HTTPException(status_code=404, detail="Related product relationship not found")
# Update the relationship
db.execute(
models.related_products.update().where(models.related_products.c.id == relationship_id).values(
relationship_type=update_data.relationship_type
)
)
db.commit()
# Get the updated relationship
updated_relationship = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
return schemas.RelatedProduct(
id=updated_relationship.id,
product_id=updated_relationship.product_id,
related_product_id=updated_relationship.related_product_id,
relationship_type=updated_relationship.relationship_type,
created_at=updated_relationship.created_at
)
@app.delete("/related-products/{relationship_id}")
def delete_related_product(relationship_id: int, db: Session = Depends(get_db)):
# Check if relationship exists
existing = db.execute(
models.related_products.select().where(models.related_products.c.id == relationship_id)
).first()
if existing is None:
raise HTTPException(status_code=404, detail="Related product relationship not found")
# Delete the relationship
db.execute(
models.related_products.delete().where(models.related_products.c.id == relationship_id)
)
db.commit()
return {"message": "Related product relationship deleted successfully"}
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)

View File

@@ -1,35 +1,215 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Table from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Date, ForeignKey, Table, event, DDL
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from sqlalchemy.sql import func from sqlalchemy.sql import func, text
from datetime import datetime from datetime import datetime
# Constants for temporal tables
FAR_FUTURE_DATE = "'9999-12-31'"
Base = declarative_base() Base = declarative_base()
# Association table for many-to-many relationship between shopping events and groceries # Trigger creation SQL
shopping_event_groceries = Table( PRODUCTS_VERSIONING_TRIGGER_SQL = """
'shopping_event_groceries', CREATE OR REPLACE FUNCTION products_versioning_trigger()
RETURNS TRIGGER AS $$
BEGIN
-- Handle DELETE operations
IF TG_OP = 'DELETE' THEN
-- Create history record for the deleted product
INSERT INTO products_history (
id, name, category_id, brand_id, organic, weight, weight_unit,
created_at, updated_at, valid_from, valid_to, deleted, operation
) VALUES (
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
OLD.valid_from, CURRENT_DATE, OLD.deleted, 'D'
);
RETURN OLD;
END IF;
-- Handle UPDATE operations
IF TG_OP = 'UPDATE' THEN
-- Check if any versioned fields have changed
IF (OLD.name IS DISTINCT FROM NEW.name OR
OLD.category_id IS DISTINCT FROM NEW.category_id OR
OLD.brand_id IS DISTINCT FROM NEW.brand_id OR
OLD.organic IS DISTINCT FROM NEW.organic OR
OLD.weight IS DISTINCT FROM NEW.weight OR
OLD.weight_unit IS DISTINCT FROM NEW.weight_unit OR
OLD.deleted IS DISTINCT FROM NEW.deleted) THEN
-- Determine the valid_to date for the history record
DECLARE
history_valid_to DATE;
BEGIN
-- If valid_from was manually changed, use that as the cutoff
-- Otherwise, use current date for automatic versioning
IF OLD.valid_from IS DISTINCT FROM NEW.valid_from THEN
history_valid_to = NEW.valid_from;
ELSE
history_valid_to = CURRENT_DATE;
-- For automatic versioning, update the valid_from to today
NEW.valid_from = CURRENT_DATE;
END IF;
-- Create history record with the old data
INSERT INTO products_history (
id, name, category_id, brand_id, organic, weight, weight_unit,
created_at, updated_at, valid_from, valid_to, deleted, operation
) VALUES (
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
OLD.valid_from, history_valid_to, OLD.deleted, 'U'
);
END;
-- Always ensure valid_to is set to far future for current version
NEW.valid_to = '9999-12-31';
NEW.updated_at = NOW();
END IF;
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS products_versioning_trigger ON products;
CREATE TRIGGER products_versioning_trigger
BEFORE UPDATE OR DELETE ON products
FOR EACH ROW
EXECUTE FUNCTION products_versioning_trigger();
"""
# Association table for many-to-many relationship between shopping events and products
shopping_event_products = Table(
'shopping_event_products',
Base.metadata, Base.metadata,
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), primary_key=True), Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
Column('grocery_id', Integer, ForeignKey('groceries.id'), primary_key=True), Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
Column('amount', Float, nullable=False) # Amount of this grocery bought in this event Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('amount', Float, nullable=False), # Amount of this product bought in this event
Column('price', Float, nullable=False), # Price of this product at the time of this shopping event
Column('discount', Boolean, default=False, nullable=False) # Whether this product was purchased with a discount
) )
class Grocery(Base): # Association table for many-to-many self-referential relationship between related products
__tablename__ = "groceries" related_products = Table(
'related_products',
Base.metadata,
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('related_product_id', Integer, ForeignKey('products.id'), nullable=False),
Column('relationship_type', String, nullable=True), # Optional: e.g., "size_variant", "brand_variant", "similar"
Column('created_at', DateTime(timezone=True), server_default=func.now())
)
class BrandInShop(Base):
__tablename__ = "brands_in_shops"
id = Column(Integer, primary_key=True, index=True)
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
shop = relationship("Shop", back_populates="brands_in_shop")
brand = relationship("Brand", back_populates="shops_with_brand")
class Brand(Base):
__tablename__ = "brands"
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)
price = Column(Float, nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now())
category = Column(String, nullable=False) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Relationships
products = relationship("Product", back_populates="brand")
shops_with_brand = relationship("BrandInShop", 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
products = relationship("Product", back_populates="category")
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, index=True)
name = Column(String, nullable=False, index=True)
category_id = Column(Integer, ForeignKey("grocery_categories.id"), nullable=False)
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"
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()) updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Temporal columns for versioning
valid_from = Column(Date, server_default=func.current_date(), nullable=False)
valid_to = Column(Date, server_default=text(FAR_FUTURE_DATE), nullable=False)
deleted = Column(Boolean, default=False, nullable=False)
# Relationships # Relationships
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_groceries, back_populates="groceries") category = relationship("GroceryCategory", back_populates="products")
brand = relationship("Brand", back_populates="products")
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
# Self-referential many-to-many relationship for related products
# We'll use a simpler approach without back_populates to avoid circular references
related_products = relationship(
"Product",
secondary=related_products,
primaryjoin="Product.id == related_products.c.product_id",
secondaryjoin="Product.id == related_products.c.related_product_id",
viewonly=True
)
class ProductHistory(Base):
__tablename__ = "products_history"
history_id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, nullable=False, index=True) # Original product ID
name = Column(String, nullable=False)
category_id = Column(Integer, nullable=False)
brand_id = Column(Integer, nullable=True)
organic = Column(Boolean, default=False)
weight = Column(Float, nullable=True)
weight_unit = Column(String, default="piece")
created_at = Column(DateTime(timezone=True))
updated_at = Column(DateTime(timezone=True))
# Temporal columns
valid_from = Column(Date, nullable=False)
valid_to = Column(Date, nullable=False)
deleted = Column(Boolean, default=False, nullable=False)
# Audit columns
operation = Column(String(1), nullable=False) # 'U' for Update, 'D' for Delete
archived_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Create trigger after ALL tables are created
@event.listens_for(Base.metadata, 'after_create')
def create_products_versioning_trigger_after_all_tables(target, connection, **kw):
"""Create the products versioning trigger after all tables are created"""
try:
connection.execute(text(PRODUCTS_VERSIONING_TRIGGER_SQL))
print("✅ Products versioning trigger created successfully")
except Exception as e:
print(f"⚠️ Warning: Could not create products versioning trigger: {e}")
# Don't fail the entire application startup if trigger creation fails
pass
class Shop(Base): class Shop(Base):
__tablename__ = "shops" __tablename__ = "shops"
@@ -39,9 +219,11 @@ 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")
brands_in_shop = relationship("BrandInShop", back_populates="shop")
class ShoppingEvent(Base): class ShoppingEvent(Base):
__tablename__ = "shopping_events" __tablename__ = "shopping_events"
@@ -52,7 +234,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")

View File

@@ -1,7 +1,7 @@
fastapi>=0.104.1 fastapi>=0.104.1
uvicorn[standard]>=0.24.0 uvicorn[standard]>=0.24.0
sqlalchemy>=2.0.23 sqlalchemy>=2.0.23
psycopg[binary]>=3.2.2 psycopg2-binary>=2.9.7
alembic>=1.12.1 alembic>=1.12.1
pydantic>=2.5.0 pydantic>=2.5.0
python-jose[cryptography]>=3.3.0 python-jose[cryptography]>=3.3.0
@@ -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

View File

@@ -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

View File

@@ -1,31 +1,140 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional, List from typing import Optional, List
from datetime import datetime from datetime import datetime, date
# Base schemas # Brand schemas
class GroceryBase(BaseModel): class BrandBase(BaseModel):
name: str name: str
price: float = Field(..., gt=0)
category: 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
# BrandInShop schemas
class BrandInShopBase(BaseModel):
shop_id: int
brand_id: int
class BrandInShopCreate(BrandInShopBase):
pass
class BrandInShopUpdate(BaseModel):
shop_id: Optional[int] = None
brand_id: Optional[int] = None
class BrandInShop(BrandInShopBase):
id: int
created_at: datetime
updated_at: Optional[datetime] = None
shop: "Shop"
brand: "Brand"
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
# Product schemas
class ProductBase(BaseModel):
name: str
category_id: int
brand_id: Optional[int] = None
organic: bool = False organic: bool = False
weight: Optional[float] = None weight: Optional[float] = None
weight_unit: str = "g" weight_unit: str = "g"
class GroceryCreate(GroceryBase): class ProductCreate(ProductBase):
pass valid_from: Optional[date] = None # If not provided, will use current date
class GroceryUpdate(BaseModel): class ProductUpdate(BaseModel):
name: Optional[str] = None name: Optional[str] = None
price: Optional[float] = Field(None, gt=0) category_id: Optional[int] = None
category: Optional[str] = None brand_id: Optional[int] = None
organic: Optional[bool] = None organic: Optional[bool] = None
weight: Optional[float] = None weight: Optional[float] = None
weight_unit: Optional[str] = None weight_unit: Optional[str] = None
valid_from: Optional[date] = None # If not provided, will use current date
class Grocery(GroceryBase): class Product(ProductBase):
id: int id: int
created_at: datetime created_at: datetime
updated_at: Optional[datetime] = None updated_at: Optional[datetime] = None
category: GroceryCategory
brand: Optional[Brand] = None
class Config:
from_attributes = True
# Historical Product schemas
class ProductHistory(BaseModel):
history_id: int
id: int # Original product ID
name: str
category_id: int
brand_id: Optional[int] = None
organic: bool = False
weight: Optional[float] = None
weight_unit: str = "g"
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
valid_from: date
valid_to: date
deleted: bool
operation: str # 'U' for Update, 'D' for Delete
archived_at: datetime
class Config:
from_attributes = True
class ProductAtDate(BaseModel):
id: int
name: str
category_id: int
category: GroceryCategory
brand_id: Optional[int] = None
brand: Optional[Brand] = None
organic: bool = False
weight: Optional[float] = None
weight_unit: str = "g"
valid_from: date
valid_to: date
deleted: bool
was_current: bool # True if from current table, False if from history
class Config:
from_attributes = True
class ProductAtPurchase(BaseModel):
product: ProductAtDate
amount: float
price: float
discount: bool
class Config: class Config:
from_attributes = True from_attributes = True
@@ -47,14 +156,32 @@ 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)
discount: bool = False # Whether this product was purchased with a discount
class ProductWithEventData(BaseModel):
id: int
name: str
category: GroceryCategory
brand: Optional[Brand] = None
organic: bool
weight: Optional[float] = None
weight_unit: str
amount: float # Amount purchased in this event
price: float # Price at the time of this event
discount: bool # Whether this product was purchased with a discount
class Config:
from_attributes = True
class ShoppingEventBase(BaseModel): class ShoppingEventBase(BaseModel):
shop_id: int shop_id: int
@@ -63,20 +190,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[Grocery] = [] products: List[ProductWithEventData] = []
class Config: class Config:
from_attributes = True from_attributes = True
@@ -93,3 +221,35 @@ class ShopStats(BaseModel):
total_spent: float total_spent: float
visit_count: int visit_count: int
avg_per_visit: float avg_per_visit: float
# Update forward references
BrandInShop.model_rebuild()
# Related Products schemas
class RelatedProductBase(BaseModel):
product_id: int
related_product_id: int
relationship_type: Optional[str] = None
class RelatedProductCreate(RelatedProductBase):
pass
class RelatedProductUpdate(BaseModel):
relationship_type: Optional[str] = None
class RelatedProduct(RelatedProductBase):
id: int
created_at: datetime
class Config:
from_attributes = True
# Product with related products
class ProductWithRelated(Product):
related_products: List["Product"] = []
class Config:
from_attributes = True
# Update forward references for classes that reference other classes
ProductWithRelated.model_rebuild()

8
backend/version.py Normal file
View File

@@ -0,0 +1,8 @@
"""
Version configuration for Groceries Tracker Backend
Single source of truth for version information
"""
__version__ = "1.1.0"
__app_name__ = "Groceries Tracker API"
__description__ = "API for tracking grocery shopping events, products, and expenses with historical data support"

696
database_schema.drawio Normal file
View File

@@ -0,0 +1,696 @@
<mxfile host="65bd71144e">
<diagram name="Product Tracker Database Schema" id="database-schema">
<mxGraphModel dx="1940" dy="562" 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="110" y="10" width="320" height="40" as="geometry"/>
</mxCell>
<mxCell id="2" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="420" y="470" width="180" height="300" as="geometry"/>
</mxCell>
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="4" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="5" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="6" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="7" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="8" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="128" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="129" value="FK" 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="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="9" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="11" value="categorie_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="12" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="13" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="14" value="organic: BOOLEAN" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="18" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="19" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="18" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="20" value="weight: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="18" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="36" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="210" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="37" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="36" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="38" value="weight_unit: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="36" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="21" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="240" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="22" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="21" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="23" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="21" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="15" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
<mxGeometry y="270" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="16" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="15" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="17" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="15" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="39" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_events&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="420" y="110" width="180" height="240" as="geometry"/>
</mxCell>
<mxCell id="40" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="39" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="41" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="42" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="43" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="44" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="45" value="shop_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="46" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="47" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="48" value="date: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="49" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="50" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="51" value="total_amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="52" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="53" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="52" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="54" value="notes: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="52" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="58" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="59" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="58" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="60" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="58" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="111" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
<mxGeometry y="210" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="112" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="111" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="113" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="111" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="70" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shops&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="120" y="90" width="180" height="210" as="geometry"/>
</mxCell>
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="72" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="73" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="74" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="75" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="76" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="77" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="78" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="79" value="city: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="80" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="81" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="82" value="address: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="89" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="90" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="89" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="91" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="89" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="92" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;strokeColor=#b85450;" parent="70" vertex="1">
<mxGeometry y="180" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="93" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="92" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="94" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="92" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="95" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;shopping_event_products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="760" y="210" width="220" height="210" 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="220" 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="190" height="30" as="geometry">
<mxRectangle width="190" 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="220" 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="190" height="30" as="geometry">
<mxRectangle width="190" 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="220" 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="190" height="30" as="geometry">
<mxRectangle width="190" 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="220" 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="190" height="30" as="geometry">
<mxRectangle width="190" 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="220" 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="190" height="30" as="geometry">
<mxRectangle width="190" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="204" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="95">
<mxGeometry y="180" width="220" height="30" as="geometry"/>
</mxCell>
<mxCell id="205" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="204">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="206" value="discount: BOOLEAN" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="204">
<mxGeometry x="30" width="190" height="30" as="geometry">
<mxRectangle width="190" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="114" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="-410" y="400" 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="148" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;grocerie_categories&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="30" y="580" 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;" parent="148" vertex="1">
<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;" parent="149" vertex="1">
<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;" parent="149" vertex="1">
<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;" parent="148" vertex="1">
<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;" parent="152" vertex="1">
<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;" parent="152" vertex="1">
<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;" parent="148" vertex="1">
<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;" parent="155" vertex="1">
<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;" parent="155" vertex="1">
<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;" parent="148" vertex="1">
<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;" parent="158" vertex="1">
<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;" parent="158" vertex="1">
<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;" parent="1" source="149" target="9" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="270" y="785" as="sourcePoint"/>
<mxPoint x="90" y="805" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="199" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="187" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="280" y="755" as="sourcePoint"/>
<mxPoint x="430" y="615" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="200" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="115" target="190" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="90" y="135" as="sourcePoint"/>
<mxPoint x="-21" y="352" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="183" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;brands_in_shops&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="-160" y="210" width="180" height="180" as="geometry"/>
</mxCell>
<mxCell id="184" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="183" vertex="1">
<mxGeometry y="30" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="185" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="184" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="186" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="184" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="187" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
<mxGeometry y="60" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="188" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="187" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="189" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: nowrap;&quot;&gt;shop_id: INTEGER&lt;/span&gt;" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="187" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="190" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
<mxGeometry y="90" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="191" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="190" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="192" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="190" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="193" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
<mxGeometry y="120" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="194" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="193" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="195" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="193" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="196" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="183" vertex="1">
<mxGeometry y="150" width="180" height="30" as="geometry"/>
</mxCell>
<mxCell id="197" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="196" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="198" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="196" vertex="1">
<mxGeometry x="30" width="150" height="30" as="geometry">
<mxRectangle width="150" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-200" value="&lt;span style=&quot;color: rgb(0, 0, 0); text-wrap-mode: wrap;&quot;&gt;related_products&lt;/span&gt;" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
<mxGeometry x="790" y="470" width="200" height="180" as="geometry"/>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-201" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="rvE4wdXwnSLMpUZ5b23a-200" vertex="1">
<mxGeometry y="30" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-202" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="rvE4wdXwnSLMpUZ5b23a-201" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-203" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="rvE4wdXwnSLMpUZ5b23a-201" vertex="1">
<mxGeometry x="30" width="170" height="30" as="geometry">
<mxRectangle width="170" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-204" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="rvE4wdXwnSLMpUZ5b23a-200" vertex="1">
<mxGeometry y="60" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-205" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="rvE4wdXwnSLMpUZ5b23a-204" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-206" value="relationship_type: 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="rvE4wdXwnSLMpUZ5b23a-204" vertex="1">
<mxGeometry x="30" width="170" height="30" as="geometry">
<mxRectangle width="170" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-214" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="rvE4wdXwnSLMpUZ5b23a-200" vertex="1">
<mxGeometry y="90" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-215" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="rvE4wdXwnSLMpUZ5b23a-214" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-216" value="product_id" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="rvE4wdXwnSLMpUZ5b23a-214" vertex="1">
<mxGeometry x="30" width="170" height="30" as="geometry">
<mxRectangle width="170" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-217" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="rvE4wdXwnSLMpUZ5b23a-200" vertex="1">
<mxGeometry y="120" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-218" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="rvE4wdXwnSLMpUZ5b23a-217" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-219" value="related_product_id" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="rvE4wdXwnSLMpUZ5b23a-217" vertex="1">
<mxGeometry x="30" width="170" height="30" as="geometry">
<mxRectangle width="170" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-207" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="rvE4wdXwnSLMpUZ5b23a-200" vertex="1">
<mxGeometry y="150" width="200" height="30" as="geometry"/>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-208" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="rvE4wdXwnSLMpUZ5b23a-207" vertex="1">
<mxGeometry width="30" height="30" as="geometry">
<mxRectangle width="30" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-209" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="rvE4wdXwnSLMpUZ5b23a-207" vertex="1">
<mxGeometry x="30" width="170" height="30" as="geometry">
<mxRectangle width="170" height="30" as="alternateBounds"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-220" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="3" target="rvE4wdXwnSLMpUZ5b23a-214" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="700" y="810" as="sourcePoint"/>
<mxPoint x="910" y="790" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
<mxCell id="rvE4wdXwnSLMpUZ5b23a-221" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="3" target="rvE4wdXwnSLMpUZ5b23a-217" edge="1">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="710" y="580" as="sourcePoint"/>
<mxPoint x="880" y="700" as="targetPoint"/>
<Array as="points"/>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

73
docker-compose.yml Normal file
View File

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

11
docker.env.example Normal file
View File

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

31
frontend/Dockerfile Normal file
View File

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

26
frontend/nginx.conf Normal file
View File

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

View File

@@ -1,17 +1,19 @@
{ {
"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",
"@types/papaparse": "^5.3.16",
"@types/react": "^18.2.45", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"axios": "^1.6.2", "axios": "^1.6.2",
"papaparse": "^5.5.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
@@ -4279,6 +4281,15 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/papaparse": {
"version": "5.3.16",
"resolved": "https://registry.npmjs.org/@types/papaparse/-/papaparse-5.3.16.tgz",
"integrity": "sha512-T3VuKMC2H0lgsjI9buTB3uuKj3EMD2eap1MOuEQuBQ44EnDx/IkGhU6EwiTf9zG3za4SKlmwKAImdDKdNnCsXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/parse-json": { "node_modules/@types/parse-json": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz",
@@ -13720,6 +13731,12 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0" "license": "BlueOak-1.0.0"
}, },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
"integrity": "sha512-5QvjGxYVjxO59MGU2lHVYpRWBBtKHnlIAcSe1uNFCkkptUh63NFRj0FJQm7nR67puEruUci/ZkjmEFrjCAyP4A==",
"license": "MIT"
},
"node_modules/param-case": { "node_modules/param-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz",

View File

@@ -1,18 +1,20 @@
{ {
"name": "grocery-tracker-frontend", "name": "groceries-tracker-frontend",
"version": "0.1.0", "version": "1.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@types/node": "^20.10.5", "@types/node": "^20.10.5",
"@types/papaparse": "^5.3.16",
"@types/react": "^18.2.45", "@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18", "@types/react-dom": "^18.2.18",
"axios": "^1.6.2",
"papaparse": "^5.5.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.20.1", "react-router-dom": "^6.20.1",
"react-scripts": "5.0.1", "react-scripts": "5.0.1",
"typescript": "^4.9.5", "typescript": "^4.9.5",
"web-vitals": "^3.5.0", "web-vitals": "^3.5.0"
"axios": "^1.6.2"
}, },
"scripts": { "scripts": {
"start": "react-scripts start", "start": "react-scripts start",
@@ -40,8 +42,8 @@
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.8", "@types/jest": "^29.5.8",
"tailwindcss": "^3.3.6",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.32" "postcss": "^8.4.32",
"tailwindcss": "^3.3.6"
} }
} }

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32">
<rect width="32" height="32" rx="6" fill="#10B981"/>
<g fill="white">
<!-- Shopping cart -->
<path d="M7 8h2l1.68 7.39a2 2 0 0 0 2 1.61H20a2 2 0 0 0 2-1.61L24 10H11"/>
<circle cx="14" cy="23" r="1"/>
<circle cx="20" cy="23" r="1"/>
<!-- Handle -->
<path d="M7 8h-2"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 394 B

View File

@@ -3,13 +3,16 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="icon" href="%PUBLIC_URL%/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/favicon.svg" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" /> <meta name="theme-color" content="#10B981" />
<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>Groceries 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>

View File

@@ -0,0 +1,22 @@
{
"short_name": "Groceries",
"name": "Groceries Tracker",
"description": "Track product prices and shopping events",
"icons": [
{
"src": "favicon.ico",
"sizes": "16x16 32x32 48x48",
"type": "image/x-icon"
},
{
"src": "favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#10B981",
"background_color": "#ffffff"
}

View File

@@ -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

View File

@@ -1,72 +1,174 @@
import React from 'react'; import React, { useState } 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 BrandList from './components/BrandList';
import GroceryCategoryList from './components/GroceryCategoryList';
import ImportExportModal from './components/ImportExportModal';
import AboutModal from './components/AboutModal';
function Navigation({ onImportExportClick, onAboutClick }: { onImportExportClick: () => void; onAboutClick: () => void }) {
const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const isActive = (path: string) => {
return location.pathname === path;
};
const navLinks = [
{ path: '/', label: 'Dashboard' },
{ path: '/shopping-events', label: 'Shopping Events' },
{ path: '/shops', label: 'Shops' },
{ path: '/products', label: 'Products' },
{ path: '/brands', label: 'Brands' },
{ path: '/categories', label: 'Categories' }
];
const NavLink = ({ path, label, mobile = false }: { path: string; label: string; mobile?: boolean }) => (
<Link
to={path}
onClick={() => mobile && setIsMobileMenuOpen(false)}
className={`${mobile ? 'block px-3 py-2 text-base font-medium' : 'inline-flex items-center px-1 pt-1 text-sm font-medium'} ${
isActive(path)
? mobile
? 'text-blue-600 bg-blue-50 border-l-4 border-blue-600'
: 'text-white border-b-2 border-white'
: mobile
? 'text-gray-600 hover:text-blue-600 hover:bg-gray-50'
: 'text-blue-100 hover:text-white'
}`}
>
{label}
</Link>
);
function App() {
return ( return (
<Router> <nav className="bg-blue-600 shadow-lg">
<div className="min-h-screen bg-gray-50"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Navigation */} <div className="flex justify-between h-16">
<nav className="bg-white shadow-lg"> {/* Desktop Navigation */}
<div className="max-w-7xl mx-auto px-4"> <div className="hidden md:flex space-x-8">
<div className="flex justify-between h-16"> {navLinks.map(({ path, label }) => (
<div className="flex"> <NavLink key={path} path={path} label={label} />
<div className="flex-shrink-0 flex items-center"> ))}
<h1 className="text-xl font-bold text-gray-800"> </div>
🛒 Grocery Tracker
</h1> {/* Mobile menu button */}
</div> <div className="md:hidden flex items-center">
<div className="hidden sm:ml-6 sm:flex sm:space-x-8"> <button
<Link onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
to="/" className="inline-flex items-center justify-center p-2 rounded-md text-blue-100 hover:text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium" aria-expanded="false"
> >
Dashboard <span className="sr-only">Open main menu</span>
</Link> {!isMobileMenuOpen ? (
<Link <svg className="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
to="/groceries" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium" </svg>
> ) : (
Groceries <svg className="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
</Link> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
<Link </svg>
to="/shops" )}
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium" </button>
> </div>
Shops
</Link> {/* Desktop Action Buttons */}
<Link <div className="hidden sm:flex items-center space-x-2">
to="/shopping-events" <button
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium" onClick={onImportExportClick}
> className="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-100 hover:text-white hover:bg-blue-700 rounded-md transition-colors"
Shopping Events >
</Link> <svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<Link <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M9 19l3 3m0 0l3-3m-3 3V10" />
to="/add-purchase" </svg>
className="bg-blue-500 hover:bg-blue-700 text-white inline-flex items-center px-3 py-2 text-sm font-medium rounded-md" <span className="hidden sm:inline">Import / Export</span>
> </button>
Add Purchase <button
</Link> onClick={onAboutClick}
</div> className="inline-flex items-center px-3 py-2 text-sm font-medium text-blue-100 hover:text-white hover:bg-blue-700 rounded-md transition-colors"
>
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="hidden sm:inline">About</span>
</button>
</div>
</div>
{/* Mobile menu */}
{isMobileMenuOpen && (
<div className="md:hidden">
<div className="px-2 pt-2 pb-3 space-y-1 bg-white border-t border-blue-500">
{navLinks.map(({ path, label }) => (
<NavLink key={path} path={path} label={label} mobile />
))}
{/* Mobile Action Buttons */}
<div className="border-t border-gray-200 pt-3 mt-3 space-y-1">
<button
onClick={() => {
onAboutClick();
setIsMobileMenuOpen(false);
}}
className="block w-full text-left px-3 py-2 text-base font-medium text-gray-600 hover:text-blue-600 hover:bg-gray-50"
>
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
About
</button>
</div> </div>
</div> </div>
</div> </div>
</nav> )}
</div>
</nav>
);
}
{/* Main Content */} function App() {
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8"> const [showImportExportModal, setShowImportExportModal] = useState(false);
<Routes> const [showAboutModal, setShowAboutModal] = useState(false);
<Route path="/" element={<Dashboard />} />
<Route path="/groceries" element={<GroceryList />} /> const handleDataChanged = () => {
<Route path="/shops" element={<ShopList />} /> // This will be called when data is imported, but since we're at the app level,
<Route path="/shopping-events" element={<ShoppingEventList />} /> // individual components will need to handle their own refresh
<Route path="/add-purchase" element={<ShoppingEventForm />} /> // The modal will close automatically after successful import
</Routes> };
return (
<Router>
<div className="min-h-screen bg-gray-50">
<Navigation
onImportExportClick={() => setShowImportExportModal(true)}
onAboutClick={() => setShowAboutModal(true)}
/>
<main className="py-6 md:py-10">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/shopping-events" element={<ShoppingEventList />} />
<Route path="/shops" element={<ShopList />} />
<Route path="/products" element={<ProductList />} />
<Route path="/brands" element={<BrandList />} />
<Route path="/categories" element={<GroceryCategoryList />} />
</Routes>
</div>
</main> </main>
<ImportExportModal
isOpen={showImportExportModal}
onClose={() => setShowImportExportModal(false)}
onDataChanged={handleDataChanged}
/>
<AboutModal
isOpen={showAboutModal}
onClose={() => setShowAboutModal(false)}
/>
</div> </div>
</Router> </Router>
); );

View File

@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
import { VERSION } from '../config/version';
// Use the same API base URL as other API calls
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '/api' // Use nginx proxy in production
: 'http://localhost:8000'; // Direct backend connection in development
interface AboutModalProps {
isOpen: boolean;
onClose: () => void;
}
const AboutModal: React.FC<AboutModalProps> = ({ isOpen, onClose }) => {
// Use body scroll lock when modal is open
useBodyScrollLock(isOpen);
const [backendVersion, setBackendVersion] = useState<string>('Loading...');
useEffect(() => {
if (isOpen) {
fetchBackendVersion();
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
const fetchBackendVersion = async () => {
try {
const response = await fetch(`${API_BASE_URL}/`);
const data = await response.json();
setBackendVersion(data.version || 'Unknown');
} catch (error) {
console.error('Error fetching backend version:', error);
setBackendVersion('Error loading');
}
};
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"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative top-20 mx-auto p-6 border w-full max-w-md shadow-lg rounded-md bg-white"
onClick={(e) => e.stopPropagation()}
>
<div className="mt-3">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold text-gray-900">
About {VERSION.name}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-1"
>
<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>
<div className="space-y-4">
{/* App Info */}
<div className="text-center mb-6">
<div className="w-16 h-16 mx-auto mb-4 bg-blue-100 rounded-full flex items-center justify-center">
<svg className="w-8 h-8 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z" />
</svg>
</div>
<p className="text-gray-600 text-sm">
A comprehensive grocery shopping tracker to manage your shopping events, products, and expenses.
</p>
</div>
{/* Version Information */}
<div className="bg-gray-50 rounded-lg p-4">
<h4 className="font-medium text-gray-900 mb-3">Version Information</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Frontend:</span>
<span className="font-mono text-gray-900">v{VERSION.frontend}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Backend:</span>
<span className="font-mono text-gray-900">v{backendVersion}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Build Date:</span>
<span className="font-mono text-gray-900">{VERSION.buildDate}</span>
</div>
</div>
</div>
{/* Features */}
<div className="bg-blue-50 rounded-lg p-4">
<h4 className="font-medium text-blue-900 mb-3">Key Features</h4>
<ul className="text-sm text-blue-800 space-y-1">
<li> Track shopping events and expenses</li>
<li> Manage products, brands, and categories</li>
<li> Mobile-responsive design</li>
<li> Import/Export data functionality</li>
<li> Real-time calculations and analytics</li>
</ul>
</div>
{/* Technical Info */}
<div className="bg-green-50 rounded-lg p-4">
<h4 className="font-medium text-green-900 mb-3">Technology Stack</h4>
<div className="text-sm text-green-800 space-y-1">
<div><strong>Frontend:</strong> React, TypeScript, Tailwind CSS</div>
<div><strong>Backend:</strong> FastAPI, Python, SQLite</div>
<div><strong>Mobile:</strong> Responsive web design</div>
</div>
</div>
</div>
{/* Close Button */}
<div className="flex justify-end pt-6 mt-6 border-t border-gray-200">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
>
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default AboutModal;

View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect, useCallback } from 'react';
import { brandApi } from '../services/api';
import { Brand } from '../types';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
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;
// Use body scroll lock when modal is open
useBodyScrollLock(isOpen);
// Initialize form data when editing
useEffect(() => {
if (editBrand) {
setFormData({
name: editBrand.name
});
} else {
setFormData({
name: ''
});
}
setError('');
}, [editBrand, isOpen]);
const handleSubmit = useCallback(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);
}
}, [isEditMode, editBrand, formData.name, onBrandAdded, onClose]);
// Keyboard event handling
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) return;
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Enter' && !loading) {
event.preventDefault();
if (formData.name.trim()) {
handleSubmit(event as any);
}
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, formData.name, loading, onClose, handleSubmit]);
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"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"
onClick={(e) => e.stopPropagation()}
>
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{isEditMode ? 'Edit Brand' : 'Add New Brand'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{error && (
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
Brand Name *
</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Coca-Cola, Nestlé, Apple"
/>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? (isEditMode ? 'Updating...' : 'Adding...') : (isEditMode ? 'Update Brand' : 'Add Brand')}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddBrandModal;

View File

@@ -0,0 +1,145 @@
import React, { useState, useEffect, useCallback } from 'react';
import { GroceryCategory, GroceryCategoryCreate } from '../types';
import { groceryCategoryApi } from '../services/api';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
interface AddGroceryCategoryModalProps {
category?: GroceryCategory | null;
onClose: () => void;
}
const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ category, onClose }) => {
// Use body scroll lock when modal is open (always open when component is rendered)
useBodyScrollLock(true);
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 = useCallback(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.`);
setTimeout(() => setMessage(''), 3000);
} finally {
setLoading(false);
}
}, [isEditMode, category, formData, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
event.preventDefault();
if (formData.name.trim()) {
handleSubmit(event as any);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [formData, loading, onClose, handleSubmit]);
return (
<div
className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"
onClick={(e) => e.stopPropagation()}
>
<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 px-4 py-3 rounded ${
message.includes('Error')
? 'bg-red-50 border border-red-200 text-red-700'
: 'bg-green-50 border border-green-200 text-green-700'
}`}>
{message}
</div>
)}
<form onSubmit={handleSubmit}>
<div className="mb-4">
<label className="block text-gray-700 text-sm font-bold mb-2">
Category Name
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter category name"
required
/>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading
? (isEditMode ? 'Updating...' : 'Creating...')
: (isEditMode ? 'Update Category' : 'Create Category')
}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddGroceryCategoryModal;

View File

@@ -0,0 +1,468 @@
import React, { useState, useEffect, useCallback } from 'react';
import { productApi, brandApi, groceryCategoryApi, utilityApi } from '../services/api';
import { Product, Brand, GroceryCategory } from '../types';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
interface AddProductModalProps {
isOpen: boolean;
onClose: () => void;
onProductAdded: () => void;
editProduct?: Product | null;
duplicateProduct?: Product | null;
}
interface ProductFormData {
name: string;
category_id?: number;
brand_id?: number;
organic: boolean;
weight?: number;
weight_unit: string;
valid_from: string; // ISO date string (YYYY-MM-DD)
}
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct, duplicateProduct }) => {
const [formData, setFormData] = useState<ProductFormData>({
name: '',
category_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece',
valid_from: ''
});
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<GroceryCategory[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [currentDate, setCurrentDate] = useState('');
const [minValidFromDate, setMinValidFromDate] = useState('');
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
// Use body scroll lock when modal is open
useBodyScrollLock(isOpen);
// Fetch brands and categories when modal opens
useEffect(() => {
if (isOpen) {
fetchBrands();
fetchCategories();
fetchCurrentDate();
}
}, [isOpen]);
const fetchBrands = async () => {
try {
const response = await brandApi.getAll();
setBrands(response.data);
} catch (err) {
console.error('Error fetching brands:', err);
}
};
const fetchCategories = async () => {
try {
const response = await groceryCategoryApi.getAll();
setCategories(response.data);
} catch (err) {
console.error('Error fetching categories:', err);
}
};
const fetchCurrentDate = async () => {
try {
const response = await utilityApi.getCurrentDate();
// Only update if valid_from is not already set
setFormData(prev => ({
...prev,
valid_from: prev.valid_from || response.data.current_date
}));
setCurrentDate(response.data.current_date);
setMinValidFromDate(response.data.current_date);
} catch (err) {
console.error('Failed to fetch current date:', err);
// Fallback to current date if API fails
const today = new Date().toISOString().split('T')[0];
setFormData(prev => ({
...prev,
valid_from: prev.valid_from || today
}));
setCurrentDate(today);
setMinValidFromDate(today);
}
};
// Populate form when editing or duplicating
useEffect(() => {
if (editProduct && isOpen) {
// For editing, fetch the current valid_from to set proper constraints
const fetchProductValidFrom = async () => {
try {
const response = await productApi.getValidFromDate(editProduct.id);
const currentValidFrom = response.data.valid_from;
setMinValidFromDate(currentValidFrom);
setFormData({
name: editProduct.name,
category_id: editProduct.category_id,
brand_id: editProduct.brand_id,
organic: editProduct.organic,
weight: editProduct.weight,
weight_unit: editProduct.weight_unit,
valid_from: currentDate // Default to today for edits
});
} catch (err) {
console.error('Failed to fetch product valid_from:', err);
setError('Failed to load product data for editing');
}
};
if (currentDate) {
fetchProductValidFrom();
}
} else if (duplicateProduct && isOpen) {
// For duplicating, use today as default and allow any date <= today
setMinValidFromDate('1900-01-01'); // No restriction for new products
setFormData({
name: `${duplicateProduct.name} (Copy)`,
category_id: duplicateProduct.category_id,
brand_id: duplicateProduct.brand_id,
organic: duplicateProduct.organic,
weight: duplicateProduct.weight,
weight_unit: duplicateProduct.weight_unit,
valid_from: currentDate
});
} else if (isOpen && currentDate) {
// For new products, allow any date <= today
setMinValidFromDate('1900-01-01'); // No restriction for new products
setFormData({
name: '',
category_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece',
valid_from: currentDate
});
}
}, [editProduct, duplicateProduct, isOpen, currentDate]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.category_id || !formData.valid_from) {
setError('Please fill in all required fields with valid values');
return;
}
// Validate date constraints
try {
const validFromDate = new Date(formData.valid_from);
if (isNaN(validFromDate.getTime())) {
setError('Please enter a valid date');
return;
}
if (currentDate) {
const today = new Date(currentDate);
if (!isNaN(today.getTime()) && validFromDate > today) {
setError('Valid from date cannot be in the future');
return;
}
}
if (editProduct && minValidFromDate) {
// Only validate if minValidFromDate is set and valid
const minDate = new Date(minValidFromDate);
if (!isNaN(minDate.getTime()) && validFromDate <= minDate) {
setError(`Valid from date must be after the current product's valid from date (${minValidFromDate})`);
return;
}
}
} catch (dateError) {
console.error('Date validation error:', dateError);
setError('Please enter a valid date');
return;
}
try {
setLoading(true);
setError('');
const productData: any = {
name: formData.name.trim(),
category_id: formData.category_id!,
brand_id: formData.brand_id || undefined,
organic: formData.organic,
weight: formData.weight || undefined,
weight_unit: formData.weight_unit
};
// Only include valid_from if it's provided
if (formData.valid_from) {
productData.valid_from = formData.valid_from;
}
if (editProduct) {
// Update existing product
await productApi.update(editProduct.id, productData);
} else {
// Create new product
await productApi.create(productData);
}
// Reset form
setFormData({
name: '',
category_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece',
valid_from: ''
});
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);
}
}, [formData, editProduct, onProductAdded, onClose, currentDate, minValidFromDate]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
event.preventDefault();
if (formData.name.trim() && formData.category_id) {
handleSubmit(event as any);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, formData, loading, onClose, handleSubmit]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
const { 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 === 'category_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"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"
onClick={(e) => e.stopPropagation()}
>
<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' : duplicateProduct ? 'Duplicate 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="Product name"
/>
</div>
<div>
<label htmlFor="valid_from" className="block text-sm font-medium text-gray-700">
Valid from *
</label>
<input
type="date"
id="valid_from"
name="valid_from"
value={formData.valid_from}
onChange={handleChange}
required
min={editProduct && minValidFromDate ? (() => {
try {
const nextDay = new Date(minValidFromDate);
if (!isNaN(nextDay.getTime())) {
nextDay.setDate(nextDay.getDate() + 1);
return nextDay.toISOString().split('T')[0];
}
} catch (error) {
console.error('Error calculating min date:', error);
}
return undefined;
})() : undefined}
max={currentDate}
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"
/>
<p className="mt-1 text-xs text-gray-500">
{editProduct
? `Must be after ${minValidFromDate} and not in the future`
: 'The date when this product information becomes effective (cannot be in the future)'
}
</p>
</div>
<div>
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">
Category *
</label>
<select
id="category_id"
name="category_id"
value={formData.category_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 category</option>
{categories.map(category => (
<option key={category.id} value={category.id}>
{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...' : duplicateProduct ? 'Duplicating...' : 'Adding...')
: (editProduct ? 'Update Product' : duplicateProduct ? 'Duplicate Product' : 'Add Product')
}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddProductModal;

View File

@@ -0,0 +1,335 @@
import React, { useState, useEffect, useCallback } from 'react';
import { shopApi, brandApi, brandInShopApi } from '../services/api';
import { Shop, Brand, BrandInShop } from '../types';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
interface AddShopModalProps {
isOpen: boolean;
onClose: () => void;
onShopAdded: () => void;
editShop?: Shop | null;
}
interface ShopFormData {
name: string;
city: string;
address?: string;
selectedBrands: number[];
}
const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdded, editShop }) => {
const [formData, setFormData] = useState<ShopFormData>({
name: '',
city: '',
address: '',
selectedBrands: []
});
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!editShop;
// Use body scroll lock when modal is open
useBodyScrollLock(isOpen);
// Load brands when modal opens
useEffect(() => {
if (isOpen) {
fetchBrands();
if (editShop) {
loadShopBrands(editShop.id);
}
}
}, [isOpen, editShop]);
const fetchBrands = async () => {
try {
const response = await brandApi.getAll();
setBrands(response.data);
} catch (err) {
console.error('Error fetching brands:', err);
setError('Failed to load brands. Please try again.');
}
};
const loadShopBrands = async (shopId: number) => {
try {
const response = await brandInShopApi.getByShop(shopId);
const brandIds = response.data.map((brandInShop: BrandInShop) => brandInShop.brand_id);
setFormData(prev => ({
...prev,
selectedBrands: brandIds
}));
} catch (err) {
console.error('Error loading shop brands:', err);
}
};
// Initialize form data when editing
useEffect(() => {
if (editShop) {
setFormData(prev => ({
...prev,
name: editShop.name,
city: editShop.city,
address: editShop.address || ''
}));
} else {
setFormData({
name: '',
city: '',
address: '',
selectedBrands: []
});
}
setError('');
}, [editShop, isOpen]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.city.trim()) {
setError('Please fill in all required fields');
return;
}
try {
setLoading(true);
setError('');
const trimmedAddress = formData.address?.trim();
const shopData = {
name: formData.name.trim(),
city: formData.city.trim(),
address: trimmedAddress && trimmedAddress.length > 0 ? trimmedAddress : null
};
let shopId: number;
if (isEditMode && editShop) {
await shopApi.update(editShop.id, shopData);
shopId = editShop.id;
} else {
const newShop = await shopApi.create(shopData);
shopId = newShop.data.id;
}
// Handle brand associations
if (isEditMode && editShop) {
// Get existing brand associations
const existingBrands = await brandInShopApi.getByShop(editShop.id);
const existingBrandIds = existingBrands.data.map(b => b.brand_id);
// Remove brands that are no longer selected
for (const brandInShop of existingBrands.data) {
if (!formData.selectedBrands.includes(brandInShop.brand_id)) {
await brandInShopApi.delete(brandInShop.id);
}
}
// Add new brand associations
for (const brandId of formData.selectedBrands) {
if (!existingBrandIds.includes(brandId)) {
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
}
}
} else {
// Create new brand associations for new shop
for (const brandId of formData.selectedBrands) {
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
}
}
// Reset form
setFormData({
name: '',
city: '',
address: '',
selectedBrands: []
});
onShopAdded();
onClose();
} catch (err) {
setError(`Failed to ${isEditMode ? 'update' : 'add'} shop. Please try again.`);
console.error(`Error ${isEditMode ? 'updating' : 'adding'} shop:`, err);
} finally {
setLoading(false);
}
}, [isEditMode, editShop, formData, onShopAdded, onClose]);
// Keyboard event handling
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) return;
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
event.preventDefault();
if (formData.name.trim() && formData.city.trim()) {
handleSubmit(event as any);
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, formData, loading, onClose, handleSubmit]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleBrandToggle = (brandId: number) => {
setFormData(prev => ({
...prev,
selectedBrands: prev.selectedBrands.includes(brandId)
? prev.selectedBrands.filter(id => id !== brandId)
: [...prev.selectedBrands, brandId]
}));
};
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"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white max-h-[80vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
{isEditMode ? 'Edit Shop' : 'Add New Shop'}
</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">
Shop 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., Target, Whole Foods, Kroger"
/>
</div>
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700">
City *
</label>
<input
type="text"
id="city"
name="city"
value={formData.city}
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., San Francisco, New York"
/>
</div>
<div>
<label htmlFor="address" className="block text-sm font-medium text-gray-700">
Address (Optional)
</label>
<textarea
id="address"
name="address"
value={formData.address}
onChange={handleChange}
rows={3}
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., 123 Main St, Downtown"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Available Brands (Optional)
</label>
<div className="max-h-40 overflow-y-auto border border-gray-300 rounded-md p-3 bg-gray-50">
{brands.length === 0 ? (
<p className="text-sm text-gray-500">Loading brands...</p>
) : (
<div className="space-y-2">
{brands.map(brand => (
<label key={brand.id} className="flex items-center">
<input
type="checkbox"
checked={formData.selectedBrands.includes(brand.id)}
onChange={() => handleBrandToggle(brand.id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-900">{brand.name}</span>
</label>
))}
</div>
)}
</div>
<p className="mt-1 text-xs text-gray-500">
Select the brands that are available in this shop
</p>
</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 Shop' : 'Add Shop')}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddShopModal;

View File

@@ -0,0 +1,658 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent, BrandInShop } from '../types';
import { shopApi, productApi, shoppingEventApi, brandInShopApi } from '../services/api';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
interface AddShoppingEventModalProps {
isOpen: boolean;
onClose: () => void;
onEventAdded: () => void;
editEvent?: ShoppingEvent | null;
}
const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
isOpen,
onClose,
onEventAdded,
editEvent
}) => {
// Use body scroll lock when modal is open
useBodyScrollLock(isOpen);
const [shops, setShops] = useState<Shop[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [shopBrands, setShopBrands] = useState<BrandInShop[]>([]);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const isEditMode = Boolean(editEvent);
const [formData, setFormData] = useState<ShoppingEventCreate>({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
products: []
});
const [selectedProducts, setSelectedProducts] = useState<ProductInEvent[]>([]);
const [newProductItem, setNewProductItem] = useState<ProductInEvent>({
product_id: 0,
amount: 1,
price: 0,
discount: false
});
const [autoCalculate, setAutoCalculate] = useState<boolean>(true);
// Calculate total amount from selected products
const calculateTotal = (products: ProductInEvent[]): number => {
const total = products.reduce((total, item) => total + (item.amount * item.price), 0);
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
};
const loadEventData = useCallback(() => {
if (editEvent) {
// Use the date directly if it's already in YYYY-MM-DD format, otherwise format it
let formattedDate = editEvent.date;
if (editEvent.date.includes('T') || editEvent.date.length > 10) {
// If the date includes time or is longer than YYYY-MM-DD, extract just the date part
formattedDate = editEvent.date.split('T')[0];
}
// Map products to the format we need
const mappedProducts = editEvent.products.map(p => ({
product_id: p.id,
amount: p.amount,
price: p.price,
discount: p.discount
}));
// Calculate the sum of all products
const calculatedTotal = calculateTotal(mappedProducts);
// Check if existing total matches calculated total (with small tolerance for floating point)
const existingTotal = editEvent.total_amount || 0;
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
setFormData({
shop_id: editEvent.shop.id,
date: formattedDate,
total_amount: editEvent.total_amount,
notes: editEvent.notes || '',
products: []
});
setSelectedProducts(mappedProducts);
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
} else {
// Reset form for adding new event
setFormData({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
products: []
});
setSelectedProducts([]);
setAutoCalculate(true);
}
setMessage('');
}, [editEvent]);
useEffect(() => {
if (isOpen) {
fetchShops();
fetchProducts();
loadEventData();
}
}, [isOpen, loadEventData]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const eventData = {
...formData,
products: selectedProducts
};
if (isEditMode && editEvent) {
await shoppingEventApi.update(editEvent.id, eventData);
setMessage('Shopping event updated successfully!');
} else {
await shoppingEventApi.create(eventData);
setMessage('Shopping event created successfully!');
}
setTimeout(() => {
onEventAdded();
onClose();
}, 1500);
} catch (error) {
console.error('Error saving shopping event:', error);
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
setTimeout(() => setMessage(''), 3000);
} finally {
setLoading(false);
}
}, [formData, selectedProducts, isEditMode, editEvent, onEventAdded, onClose]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (!isOpen) return;
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
// Only trigger submit if not in a textarea and form is valid
const target = event.target as HTMLElement;
if (target.tagName !== 'TEXTAREA') {
event.preventDefault();
if (formData.shop_id > 0 && selectedProducts.length > 0) {
handleSubmit(event as any);
}
}
}
};
if (isOpen) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, formData, selectedProducts, loading, onClose, handleSubmit]);
// 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);
setMessage('Error loading shops. Please try again.');
setTimeout(() => setMessage(''), 3000);
}
};
const fetchProducts = async () => {
try {
// If we have a shopping date, get products available for that date
// Otherwise, get all non-deleted products
const response = formData.date
? await productApi.getAvailableForShopping(formData.date)
: await productApi.getAll(false); // false = don't show deleted
setProducts(response.data);
} catch (error) {
console.error('Error fetching products:', error);
setMessage('Error loading products. Please try again.');
setTimeout(() => setMessage(''), 3000);
}
};
const fetchShopBrands = async (shopId: number) => {
if (shopId === 0) {
setShopBrands([]);
return;
}
try {
const response = await brandInShopApi.getByShop(shopId);
setShopBrands(response.data);
} catch (error) {
console.error('Error fetching shop brands:', error);
setShopBrands([]);
}
};
// Effect to load shop brands when shop selection changes
useEffect(() => {
if (formData.shop_id > 0) {
fetchShopBrands(formData.shop_id);
} else {
setShopBrands([]);
}
}, [formData.shop_id]);
// Effect to refetch products when shopping date changes
useEffect(() => {
if (isOpen && formData.date) {
fetchProducts();
}
}, [formData.date, isOpen]);
const addProductToEvent = () => {
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
setNewProductItem({ product_id: 0, amount: 1, price: 0, discount: false });
}
};
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,
discount: productToEdit.discount
});
// Remove the item from the selected list
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
};
const getProductName = (id: number) => {
const product = products.find(p => p.id === id);
if (!product) return 'Unknown';
const weightInfo = product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit;
const organicEmoji = product.organic ? ' 🌱' : '';
const brandInfo = product.brand ? ` (${product.brand.name})` : '';
return `${product.name}${organicEmoji} ${weightInfo}${brandInfo}`;
};
// Filter products based on selected shop's brands
const getFilteredProducts = () => {
// If no shop is selected or shop has no brands, show all products
if (formData.shop_id === 0 || shopBrands.length === 0) {
return products;
}
// Get brand IDs available in the selected shop
const availableBrandIds = shopBrands.map(sb => sb.brand_id);
// Filter products to only show those with brands available in the shop
// Also include products without brands (brand_id is null/undefined)
return products.filter(product =>
!product.brand_id || availableBrandIds.includes(product.brand_id)
);
};
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"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative min-h-screen md:min-h-0 md:top-10 mx-auto p-4 md:p-5 w-full md:max-w-4xl md:shadow-lg md:rounded-md bg-white"
onClick={(e) => e.stopPropagation()}
>
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg md:text-xl font-medium text-gray-900">
{isEditMode ? 'Edit Shopping Event' : 'Add New Shopping Event'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 p-2"
>
<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>
{message && (
<div className={`mb-4 px-4 py-3 rounded ${
message.includes('Error')
? 'bg-red-50 border border-red-200 text-red-700'
: 'bg-green-50 border border-green-200 text-green-700'
}`}>
{message}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Shop and Date Selection */}
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Shop
</label>
<select
value={formData.shop_id}
onChange={(e) => setFormData({...formData, shop_id: parseInt(e.target.value)})}
className="w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value={0}>Select a shop</option>
{shops.map(shop => (
<option key={shop.id} value={shop.id}>
{shop.name} - {shop.city}
</option>
))}
</select>
</div>
<div className="w-full md:w-48">
<label className="block text-sm font-medium text-gray-700 mb-2">
Date
</label>
<input
type="date"
value={formData.date}
onChange={(e) => setFormData({...formData, date: e.target.value})}
className="w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
</div>
{/* Add Products Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add Products
</label>
{/* Mobile Product Form - Stacked */}
<div className="md:hidden space-y-4 mb-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Product
</label>
<select
value={newProductItem.product_id}
onChange={(e) => setNewProductItem({...newProductItem, product_id: parseInt(e.target.value)})}
className="w-full h-12 border border-gray-300 rounded-md px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>Select a product</option>
{Object.entries(
getFilteredProducts().reduce((groups, product) => {
const category = product.category.name;
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(product);
return groups;
}, {} as Record<string, typeof products>)
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, categoryProducts]) => (
<optgroup key={category} label={category}>
{categoryProducts
.sort((a, b) => a.name.localeCompare(b.name))
.map(product => (
<option key={product.id} value={product.id}>
{product.name}{product.organic ? ' 🌱' : ''}{product.weight ? ` ${product.weight}${product.weight_unit}` : product.weight_unit}{product.brand ? ` (${product.brand.name})` : ''}
</option>
))
}
</optgroup>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Amount
</label>
<input
type="number"
step="1"
min="1"
placeholder="1"
value={newProductItem.amount}
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
className="w-full h-12 border border-gray-300 rounded-md px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Price ($)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={newProductItem.price}
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
className="w-full h-12 border border-gray-300 rounded-md px-3 py-2 text-base focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
<div className="flex items-center justify-between">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={newProductItem.discount}
onChange={(e) => setNewProductItem({...newProductItem, discount: e.target.checked})}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-sm font-medium text-gray-700">Discount</span>
</label>
<button
type="button"
onClick={addProductToEvent}
className="px-6 py-3 bg-green-500 hover:bg-green-700 text-white rounded-md font-medium text-base"
>
Add Product
</button>
</div>
</div>
{/* Desktop Product Form - Horizontal */}
<div className="hidden md:flex space-x-2 mb-4">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Product
</label>
<select
value={newProductItem.product_id}
onChange={(e) => setNewProductItem({...newProductItem, product_id: parseInt(e.target.value)})}
className="w-full h-10 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 product</option>
{Object.entries(
getFilteredProducts().reduce((groups, product) => {
const category = product.category.name;
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(product);
return groups;
}, {} as Record<string, typeof products>)
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, categoryProducts]) => (
<optgroup key={category} label={category}>
{categoryProducts
.sort((a, b) => a.name.localeCompare(b.name))
.map(product => (
<option key={product.id} value={product.id}>
{product.name}{product.organic ? ' 🌱' : ''}{product.weight ? ` ${product.weight}${product.weight_unit}` : product.weight_unit}{product.brand ? ` (${product.brand.name})` : ''}
</option>
))
}
</optgroup>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Amount
</label>
<input
type="number"
step="1"
min="1"
placeholder="1"
value={newProductItem.amount}
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
className="w-24 h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Price ($)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={newProductItem.price}
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
className="w-24 h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={newProductItem.discount}
onChange={(e) => setNewProductItem({...newProductItem, discount: e.target.checked})}
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-xs font-medium text-gray-700">Discount</span>
</label>
</div>
<div className="flex items-end">
<button
type="button"
onClick={addProductToEvent}
className="px-6 py-2 bg-green-500 hover:bg-green-700 text-white rounded-md font-medium text-sm"
>
Add Product
</button>
</div>
</div>
{/* Selected Products List */}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price ($)
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Discount
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total ($)
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{selectedProducts.map((product, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{getProductName(product.product_id)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.amount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.price.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.discount ? 'Yes' : 'No'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(product.amount * product.price).toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
type="button"
onClick={() => editProductFromEvent(index)}
className="text-indigo-600 hover:text-indigo-900 mr-2"
>
Edit
</button>
<button
type="button"
onClick={() => removeProductFromEvent(index)}
className="text-red-600 hover:text-red-900"
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Total Amount and Notes */}
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Amount ($)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.total_amount}
onChange={(e) => setFormData({...formData, total_amount: parseFloat(e.target.value)})}
className="w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: e.target.value})}
className="w-full h-24 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Save Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={loading}
className="px-6 py-3 bg-blue-500 hover:bg-blue-700 text-white rounded-md font-medium text-base disabled:opacity-50"
>
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default AddShoppingEventModal;

View File

@@ -0,0 +1,305 @@
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);
const [sortField, setSortField] = useState<keyof Brand>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
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);
};
const handleSort = (field: keyof Brand) => {
if (field === sortField) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedBrands = [...brands].sort((a, b) => {
let aValue = a[sortField];
let bValue = b[sortField];
// Handle null/undefined values
if (aValue === null || aValue === undefined) aValue = '';
if (bValue === null || bValue === undefined) bValue = '';
// Convert to string for comparison
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortDirection === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
const getSortIcon = (field: keyof Brand) => {
if (sortField !== field) {
return (
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
if (sortDirection === 'asc') {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
);
} else {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Brands</h1>
<button
onClick={() => setIsModalOpen(true)}
className="w-full sm:w-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 sm:py-2 px-4 rounded text-base sm:text-sm"
>
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>
) : (
<>
{/* Desktop Table */}
<div className="hidden md:block">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('name')}
>
<div className="flex items-center">
Name
{getSortIcon('name')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('created_at')}
>
<div className="flex items-center">
Created
{getSortIcon('created_at')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('updated_at')}
>
<div className="flex items-center">
Updated
{getSortIcon('updated_at')}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedBrands.map((brand) => (
<tr key={brand.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{brand.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(brand.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{brand.updated_at ? new Date(brand.updated_at).toLocaleDateString() : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEditBrand(brand)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDeleteBrand(brand)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card Layout */}
<div className="md:hidden">
{sortedBrands.map((brand) => (
<div key={brand.id} className="border-b border-gray-200 p-4 last:border-b-0">
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{brand.name}</h3>
<p className="text-sm text-gray-500">Created: {new Date(brand.created_at).toLocaleDateString()}</p>
</div>
<div className="text-right flex-shrink-0 ml-4">
{brand.updated_at && (
<p className="text-xs text-gray-500">
Updated: {new Date(brand.updated_at).toLocaleDateString()}
</p>
)}
</div>
</div>
<div className="flex space-x-4">
<button
onClick={() => handleEditBrand(brand)}
className="flex-1 text-center py-2 px-4 border border-indigo-300 text-indigo-600 hover:bg-indigo-50 rounded-md text-sm font-medium"
>
Edit
</button>
<button
onClick={() => handleDeleteBrand(brand)}
className="flex-1 text-center py-2 px-4 border border-red-300 text-red-600 hover:bg-red-50 rounded-md text-sm font-medium"
>
Delete
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
<AddBrandModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onBrandAdded={handleBrandAdded}
editBrand={editingBrand}
/>
<ConfirmDeleteModal
isOpen={!!deletingBrand}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Brand"
message={`Are you sure you want to delete "${deletingBrand?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);
};
export default BrandList;

View File

@@ -0,0 +1,98 @@
import React, { useEffect } from 'react';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
interface ConfirmDeleteModalProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title: string;
message: string;
isLoading?: boolean;
}
const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({
isOpen,
onClose,
onConfirm,
title,
message,
isLoading = false
}) => {
// Use body scroll lock when modal is open
useBodyScrollLock(isOpen);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
} else if (event.key === 'Enter' && !isLoading) {
event.preventDefault();
onConfirm();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, isLoading, onClose, onConfirm]);
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"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white"
onClick={(e) => e.stopPropagation()}
>
<div className="mt-3">
<div className="flex items-center mb-4">
<div className="flex-shrink-0">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<div className="ml-4">
<h3 className="text-lg font-medium text-gray-900">{title}</h3>
</div>
</div>
<div className="mb-6">
<p className="text-sm text-gray-600">{message}</p>
</div>
<div className="flex justify-end space-x-3">
<button
type="button"
onClick={onClose}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Cancel
</button>
<button
type="button"
onClick={onConfirm}
disabled={isLoading}
className="px-4 py-2 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
</div>
);
};
export default ConfirmDeleteModal;

View File

@@ -1,11 +1,64 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ShoppingEvent } from '../types';
import { shoppingEventApi } from '../services/api';
const Dashboard: React.FC = () => { const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [recentEvents, setRecentEvents] = useState<ShoppingEvent[]>([]);
const [loading, setLoading] = useState(false);
// Safe date formatting function
const formatDate = (dateString: string): string => {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
return date.toLocaleDateString();
} catch (error) {
console.error('Error formatting date:', error);
return 'Invalid Date';
}
};
useEffect(() => {
fetchRecentEvents();
}, []);
const fetchRecentEvents = async () => {
try {
setLoading(true);
const response = await shoppingEventApi.getAll();
// Get the 3 most recent events
const recent = response.data
.sort((a, b) => {
try {
const dateA = new Date(b.created_at);
const dateB = new Date(a.created_at);
// Check if dates are valid
if (isNaN(dateA.getTime())) return 1;
if (isNaN(dateB.getTime())) return -1;
return dateA.getTime() - dateB.getTime();
} catch (error) {
console.error('Error sorting events by date:', error);
return 0;
}
})
.slice(0, 3);
setRecentEvents(recent);
} catch (error) {
console.error('Error fetching recent events:', error);
} finally {
setLoading(false);
}
};
return ( return (
<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-xl md:text-2xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600">Welcome to your grocery tracker!</p> <p className="text-base md: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">
@@ -69,42 +122,51 @@ const Dashboard: React.FC = () => {
{/* Quick Actions */} {/* Quick Actions */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-4 md:px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Quick Actions</h2> <h2 className="text-lg font-medium text-gray-900">Quick Actions</h2>
</div> </div>
<div className="p-6"> <div className="p-4 md:p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"> <button
<div className="p-2 bg-blue-100 rounded-md mr-3"> onClick={() => navigate('/shopping-events?add=true')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-left"
>
<div className="p-2 bg-blue-100 rounded-md mr-3 flex-shrink-0">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg> </svg>
</div> </div>
<div> <div className="min-w-0">
<p className="font-medium text-gray-900">Add Purchase</p> <p className="font-medium text-gray-900">Add New Event</p>
<p className="text-sm text-gray-600">Record a new shopping event</p> <p className="text-sm text-gray-600">Record a new shopping event</p>
</div> </div>
</button> </button>
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"> <button
<div className="p-2 bg-green-100 rounded-md mr-3"> onClick={() => navigate('/products?add=true')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-left"
>
<div className="p-2 bg-green-100 rounded-md mr-3 flex-shrink-0">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" /> <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> </svg>
</div> </div>
<div> <div className="min-w-0">
<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>
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"> <button
<div className="p-2 bg-purple-100 rounded-md mr-3"> onClick={() => navigate('/shops?add=true')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-left"
>
<div className="p-2 bg-purple-100 rounded-md mr-3 flex-shrink-0">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg> </svg>
</div> </div>
<div> <div className="min-w-0">
<p className="font-medium text-gray-900">Add Shop</p> <p className="font-medium text-gray-900">Add Shop</p>
<p className="text-sm text-gray-600">Register a new shop</p> <p className="text-sm text-gray-600">Register a new shop</p>
</div> </div>
@@ -115,17 +177,60 @@ const Dashboard: React.FC = () => {
{/* Recent Activity */} {/* Recent Activity */}
<div className="bg-white rounded-lg shadow"> <div className="bg-white rounded-lg shadow">
<div className="px-6 py-4 border-b border-gray-200"> <div className="px-4 md:px-6 py-4 border-b border-gray-200">
<h2 className="text-lg font-medium text-gray-900">Recent Shopping Events</h2> <h2 className="text-lg font-medium text-gray-900">Recent Shopping Events</h2>
</div> </div>
<div className="p-6"> <div className="p-4 md:p-6">
<div className="text-center py-8"> {loading ? (
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48"> <div className="flex justify-center items-center py-8">
<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" /> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</svg> </div>
<h3 className="mt-2 text-sm font-medium text-gray-900">No shopping events yet</h3> ) : recentEvents.length === 0 ? (
<p className="mt-1 text-sm text-gray-500">Get started by adding your first purchase!</p> <div className="text-center py-8">
</div> <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 shopping events yet</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first event!</p>
</div>
) : (
<div className="space-y-4">
{recentEvents.map((event) => (
<div key={event.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:space-x-2">
<h4 className="font-medium text-gray-900 truncate">{event.shop.name}</h4>
<span className="hidden sm:inline text-sm text-gray-500"></span>
<span className="text-sm text-gray-500">{event.shop.city}</span>
</div>
<p className="text-sm text-gray-600 mt-1">
{formatDate(event.date)}
</p>
{event.products.length > 0 && (
<p className="text-sm text-gray-500 mt-1">
{event.products.length} item{event.products.length !== 1 ? 's' : ''}
</p>
)}
</div>
<div className="text-right flex-shrink-0 ml-4">
{event.total_amount && (
<p className="font-semibold text-green-600">
${event.total_amount.toFixed(2)}
</p>
)}
<button
onClick={() => navigate('/shopping-events')}
className="text-sm text-blue-600 hover:text-blue-800 mt-1"
>
View all
</button>
</div>
</div>
</div>
))}
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,280 @@
import React, { useState, useEffect } from 'react';
import { GroceryCategory } from '../types';
import { groceryCategoryApi } from '../services/api';
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
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);
const [deletingCategory, setDeletingCategory] = useState<GroceryCategory | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [sortField, setSortField] = useState<keyof GroceryCategory>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
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 (category: GroceryCategory) => {
setDeletingCategory(category);
};
const confirmDelete = async () => {
if (!deletingCategory) return;
try {
setDeleteLoading(true);
await groceryCategoryApi.delete(deletingCategory.id);
setMessage('Category deleted successfully!');
setDeletingCategory(null);
fetchCategories();
setTimeout(() => setMessage(''), 1500);
} 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(''), 3000);
} finally {
setDeleteLoading(false);
}
};
const handleCloseDeleteModal = () => {
setDeletingCategory(null);
};
const handleEdit = (category: GroceryCategory) => {
setEditingCategory(category);
setIsModalOpen(true);
};
const handleModalClose = () => {
setIsModalOpen(false);
setEditingCategory(null);
fetchCategories();
};
const handleSort = (field: keyof GroceryCategory) => {
if (field === sortField) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedCategories = [...categories].sort((a, b) => {
let aValue = a[sortField];
let bValue = b[sortField];
// Handle null/undefined values
if (aValue === null || aValue === undefined) aValue = '';
if (bValue === null || bValue === undefined) bValue = '';
// Convert to string for comparison
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortDirection === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
const getSortIcon = (field: keyof GroceryCategory) => {
if (sortField !== field) {
return (
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
if (sortDirection === 'asc') {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
);
} else {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Grocery Categories</h1>
<button
onClick={() => setIsModalOpen(true)}
className="w-full sm:w-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 sm:py-2 px-4 rounded text-base sm:text-sm"
>
Add New Category
</button>
</div>
{message && (
<div className={`px-4 py-3 rounded ${
message.includes('Error') || message.includes('Cannot')
? 'bg-red-50 border border-red-200 text-red-700'
: 'bg-green-50 border border-green-200 text-green-700'
}`}>
{message}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{categories.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 categories</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first category.</p>
</div>
) : (
<>
{/* Desktop Table */}
<div className="hidden md:block">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('name')}
>
<div className="flex items-center">
Name
{getSortIcon('name')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('created_at')}
>
<div className="flex items-center">
Created
{getSortIcon('created_at')}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedCategories.map((category) => (
<tr key={category.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{category.name}
</div>
</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-sm font-medium">
<button
onClick={() => handleEdit(category)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDelete(category)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card Layout */}
<div className="md:hidden">
{sortedCategories.map((category) => (
<div key={category.id} className="border-b border-gray-200 p-4 last:border-b-0">
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{category.name}</h3>
<p className="text-sm text-gray-500">Created: {new Date(category.created_at).toLocaleDateString()}</p>
</div>
</div>
<div className="flex space-x-4">
<button
onClick={() => handleEdit(category)}
className="flex-1 text-center py-2 px-4 border border-indigo-300 text-indigo-600 hover:bg-indigo-50 rounded-md text-sm font-medium"
>
Edit
</button>
<button
onClick={() => handleDelete(category)}
className="flex-1 text-center py-2 px-4 border border-red-300 text-red-600 hover:bg-red-50 rounded-md text-sm font-medium"
>
Delete
</button>
</div>
</div>
))}
</div>
</>
)}
</div>
{isModalOpen && (
<AddGroceryCategoryModal
category={editingCategory}
onClose={handleModalClose}
/>
)}
<ConfirmDeleteModal
isOpen={!!deletingCategory}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Category"
message={`Are you sure you want to delete "${deletingCategory?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);
};
export default GroceryCategoryList;

View File

@@ -1,142 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Grocery } from '../types';
import { groceryApi } from '../services/api';
const GroceryList: React.FC = () => {
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
fetchGroceries();
}, []);
const fetchGroceries = async () => {
try {
setLoading(true);
const response = await groceryApi.getAll();
setGroceries(response.data);
} catch (err) {
setError('Failed to fetch groceries');
console.error('Error fetching groceries:', err);
} finally {
setLoading(false);
}
};
const handleDelete = async (id: number) => {
if (window.confirm('Are you sure you want to delete this grocery item?')) {
try {
await groceryApi.delete(id);
setGroceries(groceries.filter(g => g.id !== id));
} catch (err) {
setError('Failed to delete grocery');
console.error('Error deleting grocery:', err);
}
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
Add New Grocery
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
{error}
</div>
)}
<div className="bg-white shadow rounded-lg overflow-hidden">
{groceries.length === 0 ? (
<div className="text-center py-12">
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
</div>
) : (
<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">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Weight
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Organic
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{groceries.map((grocery) => (
<tr key={grocery.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{grocery.name}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm text-gray-900">${grocery.price.toFixed(2)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
{grocery.category}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
grocery.organic
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}>
{grocery.organic ? 'Organic' : 'Conventional'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
Edit
</button>
<button
onClick={() => handleDelete(grocery.id)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
};
export default GroceryList;

View File

@@ -0,0 +1,561 @@
import React, { useState, useEffect } from 'react';
import Papa from 'papaparse';
import { Brand, GroceryCategory, Product } from '../types';
import { brandApi, groceryCategoryApi, productApi } from '../services/api';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
interface ImportExportModalProps {
isOpen: boolean;
onClose: () => void;
onDataChanged: () => void;
}
type EntityType = 'brands' | 'categories' | 'products';
interface ImportResult {
success: number;
failed: number;
errors: string[];
}
const ImportExportModal: React.FC<ImportExportModalProps> = ({ isOpen, onClose, onDataChanged }) => {
// Use body scroll lock when modal is open
useBodyScrollLock(isOpen);
const [activeTab, setActiveTab] = useState<'export' | 'import'>('export');
const [selectedEntity, setSelectedEntity] = useState<EntityType>('brands');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [importFile, setImportFile] = useState<File | null>(null);
const [importPreview, setImportPreview] = useState<any[]>([]);
const [importResult, setImportResult] = useState<ImportResult | null>(null);
useEffect(() => {
if (!isOpen) {
// Reset state when modal closes
setActiveTab('export');
setSelectedEntity('brands');
setImportFile(null);
setImportPreview([]);
setImportResult(null);
setMessage('');
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [isOpen, onClose]);
const handleExport = async () => {
setLoading(true);
setMessage('');
try {
let data: any[] = [];
let filename = '';
switch (selectedEntity) {
case 'brands':
const brandsResponse = await brandApi.getAll();
data = brandsResponse.data.map((brand: Brand) => ({
id: brand.id,
name: brand.name,
created_at: brand.created_at,
updated_at: brand.updated_at
}));
filename = 'brands.csv';
break;
case 'categories':
const categoriesResponse = await groceryCategoryApi.getAll();
data = categoriesResponse.data.map((category: GroceryCategory) => ({
id: category.id,
name: category.name,
created_at: category.created_at,
updated_at: category.updated_at
}));
filename = 'grocery_categories.csv';
break;
case 'products':
const productsResponse = await productApi.getAll();
data = productsResponse.data.map((product: Product) => ({
id: product.id,
name: product.name,
organic: product.organic,
category_id: product.category.id,
category_name: product.category.name,
brand_id: product.brand?.id || null,
brand_name: product.brand?.name || null,
weight: product.weight,
weight_unit: product.weight_unit,
created_at: product.created_at,
updated_at: product.updated_at
}));
filename = 'products.csv';
break;
}
if (data.length === 0) {
setMessage(`No ${selectedEntity} found to export.`);
return;
}
// Convert to CSV
const csv = Papa.unparse(data);
// Create and trigger download
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
setMessage(`Successfully exported ${data.length} ${selectedEntity} to ${filename}`);
setTimeout(() => setMessage(''), 3000);
} catch (error) {
console.error('Export error:', error);
setMessage(`Failed to export ${selectedEntity}. Please try again.`);
setTimeout(() => setMessage(''), 3000);
} finally {
setLoading(false);
}
};
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.name.toLowerCase().endsWith('.csv')) {
setMessage('Please select a CSV file.');
setTimeout(() => setMessage(''), 3000);
return;
}
setImportFile(file);
setImportResult(null);
// Parse CSV for preview
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.errors.length > 0) {
setMessage('Error parsing CSV file. Please check the format.');
setTimeout(() => setMessage(''), 3000);
return;
}
setImportPreview(results.data.slice(0, 5)); // Show first 5 rows
},
error: (error) => {
console.error('CSV parse error:', error);
setMessage('Error parsing CSV file. Please check the format.');
setTimeout(() => setMessage(''), 3000);
}
});
};
const validateImportData = (data: any[]): { valid: any[], errors: string[] } => {
const valid: any[] = [];
const errors: string[] = [];
data.forEach((row, index) => {
const rowNum = index + 1;
if (!row.name || typeof row.name !== 'string' || row.name.trim().length === 0) {
errors.push(`Row ${rowNum}: Name is required and must be a non-empty string`);
return;
}
if (selectedEntity === 'products') {
if (!row.category_name || typeof row.category_name !== 'string' || row.category_name.trim().length === 0) {
errors.push(`Row ${rowNum}: Category name is required for products`);
return;
}
if (row.organic !== undefined && typeof row.organic !== 'boolean' && row.organic !== 'true' && row.organic !== 'false') {
errors.push(`Row ${rowNum}: Organic must be true/false if provided`);
return;
}
}
valid.push({
name: row.name.trim(),
category_name: selectedEntity === 'products' ? row.category_name?.trim() : undefined,
organic: selectedEntity === 'products' ? (row.organic === 'true' || row.organic === true) : undefined,
brand_name: selectedEntity === 'products' && row.brand_name ? row.brand_name.trim() : undefined,
weight: selectedEntity === 'products' && row.weight ? parseFloat(row.weight) : undefined,
weight_unit: selectedEntity === 'products' && row.weight_unit ? row.weight_unit.trim() : undefined
});
});
return { valid, errors };
};
const handleImport = async () => {
if (!importFile) return;
setLoading(true);
setMessage('');
setImportResult(null);
try {
// Parse the entire file
Papa.parse(importFile, {
header: true,
skipEmptyLines: true,
complete: async (results) => {
try {
const { valid, errors } = validateImportData(results.data);
if (errors.length > 0 && valid.length === 0) {
setMessage(`Validation failed: ${errors.join(', ')}`);
setTimeout(() => setMessage(''), 5000);
setLoading(false);
return;
}
let successCount = 0;
let failedCount = 0;
const importErrors: string[] = [...errors];
// Get categories and brands for product import
let categories: GroceryCategory[] = [];
let brands: Brand[] = [];
if (selectedEntity === 'products') {
const categoriesResponse = await groceryCategoryApi.getAll();
categories = categoriesResponse.data;
const brandsResponse = await brandApi.getAll();
brands = brandsResponse.data;
}
// Import valid records
for (const item of valid) {
try {
switch (selectedEntity) {
case 'brands':
await brandApi.create({ name: item.name });
break;
case 'categories':
await groceryCategoryApi.create({ name: item.name });
break;
case 'products':
const category = categories.find(c => c.name.toLowerCase() === item.category_name.toLowerCase());
if (!category) {
failedCount++;
importErrors.push(`Product "${item.name}": Category "${item.category_name}" not found`);
continue;
}
let brandId = null;
if (item.brand_name) {
const brand = brands.find(b => b.name.toLowerCase() === item.brand_name.toLowerCase());
if (!brand) {
failedCount++;
importErrors.push(`Product "${item.name}": Brand "${item.brand_name}" not found`);
continue;
}
brandId = brand.id;
}
await productApi.create({
name: item.name,
organic: item.organic || false,
category_id: category.id,
brand_id: brandId || undefined,
weight: item.weight,
weight_unit: item.weight_unit || 'piece'
});
break;
}
successCount++;
} catch (error: any) {
failedCount++;
const errorMsg = error.response?.data?.detail || error.message || 'Unknown error';
importErrors.push(`${item.name}: ${errorMsg}`);
}
}
setImportResult({
success: successCount,
failed: failedCount,
errors: importErrors
});
if (successCount > 0) {
onDataChanged(); // Refresh the data in parent components
}
} catch (error) {
console.error('Import processing error:', error);
setMessage('Error processing import. Please try again.');
setTimeout(() => setMessage(''), 3000);
} finally {
setLoading(false);
}
},
error: (error) => {
console.error('CSV parse error:', error);
setMessage('Error parsing CSV file. Please check the format.');
setTimeout(() => setMessage(''), 3000);
setLoading(false);
}
});
} catch (error) {
console.error('Import error:', error);
setMessage('Failed to import data. Please try again.');
setTimeout(() => setMessage(''), 3000);
setLoading(false);
}
};
const getExpectedFormat = () => {
switch (selectedEntity) {
case 'brands':
case 'categories':
return 'name';
case 'products':
return 'name,category_name (organic,brand_name,weight,weight_unit are optional)';
default:
return '';
}
};
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"
onClick={(e) => {
// Close modal if clicking on backdrop
if (e.target === e.currentTarget) {
onClose();
}
}}
>
<div
className="relative top-10 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
Import / Export Data
</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>
{message && (
<div className={`mb-4 px-4 py-3 rounded ${
message.includes('Error') || message.includes('Failed')
? 'bg-red-50 border border-red-200 text-red-700'
: 'bg-green-50 border border-green-200 text-green-700'
}`}>
{message}
</div>
)}
{/* Tabs */}
<div className="flex border-b border-gray-200 mb-6">
<button
onClick={() => setActiveTab('export')}
className={`py-2 px-4 text-sm font-medium ${
activeTab === 'export'
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Export Data
</button>
<button
onClick={() => setActiveTab('import')}
className={`py-2 px-4 text-sm font-medium ${
activeTab === 'import'
? 'border-b-2 border-blue-500 text-blue-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
Import Data
</button>
</div>
{/* Entity Selection */}
<div className="mb-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Data Type
</label>
<select
value={selectedEntity}
onChange={(e) => setSelectedEntity(e.target.value as EntityType)}
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="brands">Brands</option>
<option value="categories">Grocery Categories</option>
<option value="products">Products</option>
</select>
</div>
{/* Export Tab */}
{activeTab === 'export' && (
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-md p-4">
<h4 className="font-medium text-blue-900 mb-2">Export Information</h4>
<p className="text-sm text-blue-700 mb-2">
This will download all {selectedEntity} as a CSV file that you can open in Excel or other spreadsheet applications.
</p>
<p className="text-sm text-blue-700">
<strong>Exported fields:</strong> ID, name, {selectedEntity === 'products' ? 'organic, category_id, category_name, brand_id, brand_name, weight, weight_unit, ' : ''}created_at, updated_at
</p>
</div>
<div className="flex justify-end">
<button
onClick={handleExport}
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 ? 'Exporting...' : `Export ${selectedEntity}`}
</button>
</div>
</div>
)}
{/* Import Tab */}
{activeTab === 'import' && (
<div className="space-y-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-md p-4">
<h4 className="font-medium text-yellow-900 mb-2">Import Requirements</h4>
<p className="text-sm text-yellow-700 mb-2">
CSV file must have the following <strong>required</strong> columns: <code className="bg-yellow-100 px-1 rounded">{getExpectedFormat()}</code>
</p>
<p className="text-sm text-yellow-700 mb-2">
<strong>Note:</strong> ID, created_at, and updated_at fields are optional for import and will be ignored if present.
</p>
{selectedEntity === 'products' && (
<p className="text-sm text-yellow-700">
<strong>Products:</strong> Category names must match existing grocery categories exactly. Brand names (if provided) must match existing brands exactly.
</p>
)}
</div>
{/* File Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Select CSV File
</label>
<input
type="file"
accept=".csv"
onChange={handleFileSelect}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Preview */}
{importPreview.length > 0 && (
<div>
<h4 className="font-medium text-gray-700 mb-2">Preview (first 5 rows)</h4>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200 border border-gray-300">
<thead className="bg-gray-50">
<tr>
{Object.keys(importPreview[0]).map(key => (
<th key={key} className="px-4 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider border-r border-gray-300">
{key}
</th>
))}
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{importPreview.map((row, index) => (
<tr key={index}>
{Object.values(row).map((value: any, cellIndex) => (
<td key={cellIndex} className="px-4 py-2 text-sm text-gray-900 border-r border-gray-300">
{value}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Import Results */}
{importResult && (
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<h4 className="font-medium text-gray-700 mb-2">Import Results</h4>
<div className="text-sm space-y-1">
<p className="text-green-600"> Successfully imported: {importResult.success}</p>
<p className="text-red-600"> Failed to import: {importResult.failed}</p>
{importResult.errors.length > 0 && (
<div className="mt-3">
<p className="font-medium text-gray-700">Errors:</p>
<div className="max-h-32 overflow-y-auto bg-red-50 border border-red-200 rounded p-2 mt-1">
{importResult.errors.map((error, index) => (
<p key={index} className="text-xs text-red-700">{error}</p>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Import Button */}
{importFile && (
<div className="flex justify-end">
<button
onClick={handleImport}
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Importing...' : `Import ${selectedEntity}`}
</button>
</div>
)}
</div>
)}
{/* Close Button */}
<div className="flex justify-end pt-6 border-t border-gray-200 mt-6">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
>
Close
</button>
</div>
</div>
</div>
</div>
);
};
export default ImportExportModal;

View File

@@ -0,0 +1,398 @@
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);
const [duplicatingProduct, setDuplicatingProduct] = useState<Product | null>(null);
const [sortField, setSortField] = useState<string>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [showDeleted, setShowDeleted] = 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, showDeleted]);
const fetchProducts = async () => {
try {
setLoading(true);
const response = await productApi.getAll(showDeleted);
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 handleDuplicate = (product: Product) => {
setDuplicatingProduct(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);
setDuplicatingProduct(null);
};
const handleCloseDeleteModal = () => {
setDeletingProduct(null);
};
const handleSort = (field: string) => {
if (field === sortField) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedProducts = [...products].sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortField) {
case 'name':
aValue = a.name;
bValue = b.name;
break;
case 'category':
aValue = a.category.name;
bValue = b.category.name;
break;
case 'brand':
aValue = a.brand?.name || '';
bValue = b.brand?.name || '';
break;
case 'weight':
aValue = a.weight || 0;
bValue = b.weight || 0;
break;
default:
aValue = '';
bValue = '';
}
// Handle null/undefined values
if (aValue === null || aValue === undefined) aValue = '';
if (bValue === null || bValue === undefined) bValue = '';
// Convert to string for comparison (except for numbers)
if (typeof aValue === 'number' && typeof bValue === 'number') {
if (sortDirection === 'asc') {
return aValue - bValue;
} else {
return bValue - aValue;
}
} else {
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortDirection === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
}
});
const getSortIcon = (field: string) => {
if (sortField !== field) {
return (
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
if (sortDirection === 'asc') {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
);
} else {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Products</h1>
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={showDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Show deleted</span>
</label>
<button
onClick={() => {
setEditingProduct(null);
setDuplicatingProduct(null);
setIsModalOpen(true);
}}
className="w-full sm:w-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 sm:py-2 px-4 rounded text-base sm:text-sm"
>
Add New Product
</button>
</div>
</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>
) : (
<>
{/* Desktop Table */}
<div className="hidden md:block">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('name')}
>
<div className="flex items-center">
Name
{getSortIcon('name')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('category')}
>
<div className="flex items-center">
Category
{getSortIcon('category')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('brand')}
>
<div className="flex items-center">
Brand
{getSortIcon('brand')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('weight')}
>
<div className="flex items-center">
Weight
{getSortIcon('weight')}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedProducts.map((product) => (
<tr key={product.id} className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className={`text-sm font-medium ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
</div>
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.category.name}
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.brand ? product.brand.name : '-'}
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{!product.deleted ? (
<>
<button
onClick={() => handleEdit(product)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDuplicate(product)}
className="text-green-600 hover:text-green-900 mr-3"
>
Duplicate
</button>
<button
onClick={() => handleDelete(product)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</>
) : (
<span className="text-gray-400 text-sm">Deleted</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card Layout */}
<div className="md:hidden">
{sortedProducts.map((product) => (
<div key={product.id} className={`border-b border-gray-200 p-4 last:border-b-0 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className={`font-medium truncate ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
</h3>
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-500'}`}>{product.category.name}</p>
</div>
{product.weight && (
<div className="text-right flex-shrink-0 ml-4">
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>{product.weight}{product.weight_unit}</p>
</div>
)}
</div>
{product.brand && (
<div className="mb-3">
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>
<span className="font-medium">Brand:</span> {product.brand.name}
</p>
</div>
)}
{!product.deleted ? (
<div className="flex space-x-4">
<button
onClick={() => handleEdit(product)}
className="flex-1 text-center py-2 px-4 border border-indigo-300 text-indigo-600 hover:bg-indigo-50 rounded-md text-sm font-medium"
>
Edit
</button>
<button
onClick={() => handleDuplicate(product)}
className="flex-1 text-center py-2 px-4 border border-green-300 text-green-600 hover:bg-green-50 rounded-md text-sm font-medium"
>
Duplicate
</button>
<button
onClick={() => handleDelete(product)}
className="flex-1 text-center py-2 px-4 border border-red-300 text-red-600 hover:bg-red-50 rounded-md text-sm font-medium"
>
Delete
</button>
</div>
) : (
<div className="text-center py-2">
<span className="text-gray-400 text-sm">Deleted</span>
</div>
)}
</div>
))}
</div>
</>
)}
</div>
<AddProductModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onProductAdded={handleProductAdded}
editProduct={editingProduct}
duplicateProduct={duplicatingProduct}
/>
<ConfirmDeleteModal
isOpen={!!deletingProduct}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Product"
message={`Are you sure you want to delete "${deletingProduct?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);
};
export default ProductList;

View File

@@ -1,27 +1,232 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Shop } from '../types'; import { useSearchParams } from 'react-router-dom';
import { shopApi } from '../services/api'; import { Shop, BrandInShop } from '../types';
import { shopApi, brandInShopApi } from '../services/api';
import AddShopModal from './AddShopModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const ShopList: React.FC = () => { const ShopList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [shops, setShops] = useState<Shop[]>([]); const [shops, setShops] = useState<Shop[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingShop, setEditingShop] = useState<Shop | null>(null);
const [deletingShop, setDeletingShop] = useState<Shop | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [sortField, setSortField] = useState<keyof Shop>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [hoveredShop, setHoveredShop] = useState<Shop | null>(null);
const [showBrandsPopup, setShowBrandsPopup] = useState(false);
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
const [shopBrands, setShopBrands] = useState<Record<number, BrandInShop[]>>({});
useEffect(() => { const loadShopBrands = useCallback(async (shopId: number) => {
fetchShops(); try {
const response = await brandInShopApi.getByShop(shopId);
setShopBrands(prev => ({
...prev,
[shopId]: response.data
}));
} catch (err) {
console.error('Error loading shop brands:', err);
}
}, []); }, []);
const fetchShops = async () => { const fetchShops = useCallback(async () => {
try { try {
setLoading(true); setLoading(true);
const response = await shopApi.getAll(); const response = await shopApi.getAll();
setShops(response.data); setShops(response.data);
// Load brands for all shops
for (const shop of response.data) {
loadShopBrands(shop.id);
}
} catch (err) { } catch (err) {
setError('Failed to fetch shops'); setError('Failed to fetch shops');
console.error('Error fetching shops:', err); console.error('Error fetching shops:', err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [loadShopBrands]);
useEffect(() => {
fetchShops();
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams, fetchShops]);
// Handle clicking outside popup to close it
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (showBrandsPopup && !target.closest('.brands-popup') && !target.closest('.brands-cell')) {
setShowBrandsPopup(false);
setHoveredShop(null);
}
};
if (showBrandsPopup) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showBrandsPopup]);
const handleShopAdded = () => {
fetchShops(); // Refresh the shops list
};
const handleEditShop = (shop: Shop) => {
setEditingShop(shop);
setIsModalOpen(true);
};
const handleDeleteShop = (shop: Shop) => {
setDeletingShop(shop);
};
const confirmDelete = async () => {
if (!deletingShop) return;
try {
setDeleteLoading(true);
await shopApi.delete(deletingShop.id);
setDeletingShop(null);
fetchShops(); // Refresh the shops list
} catch (err) {
console.error('Error deleting shop:', err);
setError('Failed to delete shop. Please try again.');
} finally {
setDeleteLoading(false);
}
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingShop(null);
};
const handleCloseDeleteModal = () => {
setDeletingShop(null);
};
const handleBrandsHover = (shop: Shop, mouseEvent: React.MouseEvent) => {
const brands = shopBrands[shop.id] || [];
if (brands.length === 0) return;
const popupWidth = 300;
const popupHeight = 200;
let x = mouseEvent.clientX + 10;
let y = mouseEvent.clientY - 10;
// Adjust if popup would go off screen
if (x + popupWidth > window.innerWidth) {
x = mouseEvent.clientX - popupWidth - 10;
}
if (y + popupHeight > window.innerHeight) {
y = mouseEvent.clientY - popupHeight + 10;
}
if (y < 0) {
y = 10;
}
setHoveredShop(shop);
setPopupPosition({ x, y });
setShowBrandsPopup(true);
};
const handleBrandsLeave = () => {
setShowBrandsPopup(false);
setHoveredShop(null);
};
const handleBrandsClick = (shop: Shop, mouseEvent: React.MouseEvent) => {
const brands = shopBrands[shop.id] || [];
if (brands.length === 0) return;
mouseEvent.stopPropagation();
const popupWidth = 300;
const popupHeight = 200;
let x = mouseEvent.clientX + 10;
let y = mouseEvent.clientY - 10;
// Adjust if popup would go off screen
if (x + popupWidth > window.innerWidth) {
x = mouseEvent.clientX - popupWidth - 10;
}
if (y + popupHeight > window.innerHeight) {
y = mouseEvent.clientY - popupHeight + 10;
}
if (y < 0) {
y = 10;
}
setHoveredShop(shop);
setPopupPosition({ x, y });
setShowBrandsPopup(true);
};
const handleSort = (field: keyof Shop) => {
if (field === sortField) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedShops = [...shops].sort((a, b) => {
let aValue = a[sortField];
let bValue = b[sortField];
// Handle null/undefined values
if (aValue === null || aValue === undefined) aValue = '';
if (bValue === null || bValue === undefined) bValue = '';
// Convert to string for comparison
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortDirection === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
});
const getSortIcon = (field: keyof Shop) => {
if (sortField !== field) {
return (
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
if (sortDirection === 'asc') {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
);
} else {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
}; };
if (loading) { if (loading) {
@@ -34,9 +239,12 @@ const ShopList: React.FC = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
<h1 className="text-2xl font-bold text-gray-900">Shops</h1> <h1 className="text-xl md:text-2xl font-bold text-gray-900">Shops</h1>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> <button
onClick={() => setIsModalOpen(true)}
className="w-full sm:w-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 sm:py-2 px-4 rounded text-base sm:text-sm"
>
Add New Shop Add New Shop
</button> </button>
</div> </div>
@@ -57,51 +265,229 @@ const ShopList: React.FC = () => {
<p className="mt-1 text-sm text-gray-500">Get started by adding your first shop.</p> <p className="mt-1 text-sm text-gray-500">Get started by adding your first shop.</p>
</div> </div>
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6"> <>
{shops.map((shop) => ( {/* Desktop Table */}
<div key={shop.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow"> <div className="hidden md:block">
<div className="flex items-center justify-between mb-4"> <table className="min-w-full divide-y divide-gray-200">
<h3 className="text-lg font-medium text-gray-900">{shop.name}</h3> <thead className="bg-gray-50">
<div className="flex space-x-2"> <tr>
<button className="text-indigo-600 hover:text-indigo-900 text-sm"> <th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('name')}
>
<div className="flex items-center">
Name
{getSortIcon('name')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('city')}
>
<div className="flex items-center">
City
{getSortIcon('city')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('address')}
>
<div className="flex items-center">
Address
{getSortIcon('address')}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Brands
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('created_at')}
>
<div className="flex items-center">
Created
{getSortIcon('created_at')}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedShops.map((shop) => (
<tr key={shop.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{shop.name}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{shop.city}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{shop.address || '-'}
</td>
<td
className={`brands-cell px-6 py-4 whitespace-nowrap text-sm ${
(shopBrands[shop.id]?.length || 0) > 0
? 'text-blue-600 hover:text-blue-800 cursor-pointer hover:bg-blue-50'
: 'text-gray-900'
}`}
onMouseEnter={(e) => handleBrandsHover(shop, e)}
onMouseLeave={handleBrandsLeave}
onClick={(e) => handleBrandsClick(shop, e)}
title={(shopBrands[shop.id]?.length || 0) > 0 ? 'Click to view brands' : ''}
>
{(shopBrands[shop.id]?.length || 0) > 0 ? (
<>
{(shopBrands[shop.id]?.length || 0)} brand{(shopBrands[shop.id]?.length || 0) !== 1 ? 's' : ''}
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</>
) : (
'-'
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{new Date(shop.created_at).toLocaleDateString()}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEditShop(shop)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDeleteShop(shop)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card Layout */}
<div className="md:hidden">
{sortedShops.map((shop) => (
<div key={shop.id} className="border-b border-gray-200 p-4 last:border-b-0">
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{shop.name}</h3>
<p className="text-sm text-gray-500">{shop.city}</p>
</div>
<div className="text-right flex-shrink-0 ml-4">
<p className="text-xs text-gray-500">{new Date(shop.created_at).toLocaleDateString()}</p>
</div>
</div>
{shop.address && (
<div className="mb-3">
<p className="text-sm text-gray-600">{shop.address}</p>
</div>
)}
<div className="flex items-center justify-between mb-3">
<button
onClick={(e) => handleBrandsClick(shop, e)}
className={`text-sm ${
(shopBrands[shop.id]?.length || 0) > 0
? 'text-blue-600 hover:text-blue-800'
: 'text-gray-600'
}`}
disabled={(shopBrands[shop.id]?.length || 0) === 0}
>
{(shopBrands[shop.id]?.length || 0) > 0 ? (
<>
{(shopBrands[shop.id]?.length || 0)} brand{(shopBrands[shop.id]?.length || 0) !== 1 ? 's' : ''}
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</>
) : (
'No brands'
)}
</button>
</div>
<div className="flex space-x-4">
<button
onClick={() => handleEditShop(shop)}
className="flex-1 text-center py-2 px-4 border border-indigo-300 text-indigo-600 hover:bg-indigo-50 rounded-md text-sm font-medium"
>
Edit Edit
</button> </button>
<button className="text-red-600 hover:text-red-900 text-sm"> <button
onClick={() => handleDeleteShop(shop)}
className="flex-1 text-center py-2 px-4 border border-red-300 text-red-600 hover:bg-red-50 rounded-md text-sm font-medium"
>
Delete Delete
</button> </button>
</div> </div>
</div> </div>
))}
</div>
</>
)}
</div>
<div className="space-y-2"> <AddShopModal
<div className="flex items-center text-sm text-gray-600"> isOpen={isModalOpen}
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onClose={handleCloseModal}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" /> onShopAdded={handleShopAdded}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" /> editShop={editingShop}
</svg> />
{shop.city}
</div>
{shop.address && ( <ConfirmDeleteModal
<div className="flex items-start text-sm text-gray-600"> isOpen={!!deletingShop}
<svg className="w-4 h-4 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> onClose={handleCloseDeleteModal}
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /> onConfirm={confirmDelete}
</svg> title="Delete Shop"
{shop.address} message={`Are you sure you want to delete "${deletingShop?.name}"? This action cannot be undone.`}
</div> isLoading={deleteLoading}
)} />
<div className="flex items-center text-sm text-gray-600"> {/* Brands Popup */}
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> {showBrandsPopup && hoveredShop && (shopBrands[hoveredShop.id]?.length || 0) > 0 && (
<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" /> <div
</svg> className="brands-popup fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-sm"
Added {new Date(shop.created_at).toLocaleDateString()} style={{
</div> left: window.innerWidth < 768 ? '50%' : `${popupPosition.x}px`,
</div> top: window.innerWidth < 768 ? '50%' : `${popupPosition.y}px`,
transform: window.innerWidth < 768 ? 'translate(-50%, -50%)' : 'none',
maxHeight: '300px',
overflowY: 'auto'
}}
onMouseEnter={() => setShowBrandsPopup(true)}
onMouseLeave={handleBrandsLeave}
>
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-900">Available Brands</h4>
<button
onClick={() => setShowBrandsPopup(false)}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-4 h-4" 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>
<div className="space-y-1">
{(shopBrands[hoveredShop.id] || []).map((brandInShop, index) => (
<div key={index} className="text-sm text-gray-700">
{brandInShop.brand.name}
</div> </div>
))} ))}
</div> </div>
)} </div>
</div> )}
</div> </div>
); );
}; };

View File

@@ -1,253 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types';
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
const ShoppingEventForm: React.FC = () => {
const [shops, setShops] = useState<Shop[]>([]);
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [formData, setFormData] = useState<ShoppingEventCreate>({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
});
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
grocery_id: 0,
amount: 1
});
useEffect(() => {
fetchShops();
fetchGroceries();
}, []);
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 addGroceryToEvent = () => {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0) {
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
setNewGroceryItem({ grocery_id: 0, amount: 1 });
}
};
const removeGroceryFromEvent = (index: number) => {
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const eventData = {
...formData,
groceries: selectedGroceries
};
await shoppingEventApi.create(eventData);
setMessage('Shopping event created successfully!');
// Reset form
setFormData({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
});
setSelectedGroceries([]);
} catch (error) {
setMessage('Error creating shopping event. Please try again.');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const getGroceryName = (id: number) => {
const grocery = groceries.find(g => g.id === id);
return grocery ? grocery.name : 'Unknown';
};
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Add New Purchase
</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} className="space-y-6">
{/* Shop Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Shop
</label>
<select
value={formData.shop_id}
onChange={(e) => setFormData({...formData, shop_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"
required
>
<option value={0}>Select a shop</option>
{shops.map(shop => (
<option key={shop.id} value={shop.id}>
{shop.name} - {shop.city}
</option>
))}
</select>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date
</label>
<input
type="date"
value={formData.date}
onChange={(e) => setFormData({...formData, date: 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"
required
/>
</div>
{/* Add Groceries Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add Groceries
</label>
<div className="flex space-x-2 mb-4">
<select
value={newGroceryItem.grocery_id}
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value={0}>Select a grocery</option>
{groceries.map(grocery => (
<option key={grocery.id} value={grocery.id}>
{grocery.name} - ${grocery.price} ({grocery.category})
</option>
))}
</select>
<input
type="number"
step="0.1"
min="0.1"
placeholder="Amount"
value={newGroceryItem.amount}
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
className="w-24 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={addGroceryToEvent}
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
>
Add
</button>
</div>
{/* Selected Groceries List */}
{selectedGroceries.length > 0 && (
<div className="bg-gray-50 rounded-md p-4">
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
{selectedGroceries.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
<span>
{getGroceryName(item.grocery_id)} x {item.amount}
</span>
<button
type="button"
onClick={() => removeGroceryFromEvent(index)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
{/* Total Amount */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Amount (optional)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.total_amount || ''}
onChange={(e) => setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes (optional)
</label>
<textarea
rows={3}
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: 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"
placeholder="Any additional notes about this purchase..."
/>
</div>
{/* Submit Button */}
<div>
<button
type="submit"
disabled={loading || formData.shop_id === 0 || selectedGroceries.length === 0}
className="w-full bg-blue-500 hover:bg-blue-700 disabled:bg-gray-300 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
>
{loading ? 'Creating...' : 'Create Shopping Event'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default ShoppingEventForm;

View File

@@ -1,15 +1,54 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { ShoppingEvent } from '../types'; import { ShoppingEvent } from '../types';
import { shoppingEventApi } from '../services/api'; import { shoppingEventApi } from '../services/api';
import ConfirmDeleteModal from './ConfirmDeleteModal';
import AddShoppingEventModal from './AddShoppingEventModal';
const ShoppingEventList: React.FC = () => { const ShoppingEventList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [events, setEvents] = useState<ShoppingEvent[]>([]); const [events, setEvents] = useState<ShoppingEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [deletingEvent, setDeletingEvent] = useState<ShoppingEvent | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState<ShoppingEvent | null>(null);
const [hoveredEvent, setHoveredEvent] = useState<ShoppingEvent | null>(null);
const [showItemsPopup, setShowItemsPopup] = useState(false);
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
const [sortField, setSortField] = useState<string>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
useEffect(() => { useEffect(() => {
fetchEvents(); fetchEvents();
}, []);
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
// Handle clicking outside popup to close it
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (showItemsPopup && !target.closest('.items-popup') && !target.closest('.items-cell')) {
setShowItemsPopup(false);
setHoveredEvent(null);
}
};
if (showItemsPopup) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showItemsPopup]);
const fetchEvents = async () => { const fetchEvents = async () => {
try { try {
@@ -24,6 +63,213 @@ const ShoppingEventList: React.FC = () => {
} }
}; };
const handleDelete = (event: ShoppingEvent) => {
setDeletingEvent(event);
};
const confirmDelete = async () => {
if (!deletingEvent) return;
try {
setDeleteLoading(true);
await shoppingEventApi.delete(deletingEvent.id);
setDeletingEvent(null);
fetchEvents(); // Refresh the list
} catch (err) {
console.error('Error deleting shopping event:', err);
setError('Failed to delete shopping event. Please try again.');
} finally {
setDeleteLoading(false);
}
};
const handleCloseDeleteModal = () => {
setDeletingEvent(null);
};
const handleEdit = (event: ShoppingEvent) => {
setEditingEvent(event);
setIsModalOpen(true);
};
const handleEventAdded = () => {
fetchEvents(); // Refresh the events list
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingEvent(null);
};
const handleItemsHover = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
if (event.products.length === 0) return;
const popupWidth = 384; // max-w-md is approximately 384px
const popupHeight = 300; // max height we set
let x = mouseEvent.clientX + 10;
let y = mouseEvent.clientY - 10;
// Adjust if popup would go off screen
if (x + popupWidth > window.innerWidth) {
x = mouseEvent.clientX - popupWidth - 10;
}
if (y + popupHeight > window.innerHeight) {
y = mouseEvent.clientY - popupHeight + 10;
}
if (y < 0) {
y = 10;
}
setHoveredEvent(event);
setPopupPosition({ x, y });
setShowItemsPopup(true);
};
const handleItemsLeave = () => {
setShowItemsPopup(false);
setHoveredEvent(null);
};
const handleItemsClick = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
if (event.products.length === 0) return;
mouseEvent.stopPropagation();
const popupWidth = 384; // max-w-md is approximately 384px
const popupHeight = 300; // max height we set
let x = mouseEvent.clientX + 10;
let y = mouseEvent.clientY - 10;
// Adjust if popup would go off screen
if (x + popupWidth > window.innerWidth) {
x = mouseEvent.clientX - popupWidth - 10;
}
if (y + popupHeight > window.innerHeight) {
y = mouseEvent.clientY - popupHeight + 10;
}
if (y < 0) {
y = 10;
}
setHoveredEvent(event);
setPopupPosition({ x, y });
setShowItemsPopup(true);
};
const handleSort = (field: string) => {
if (field === sortField) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedEvents = [...events].sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortField) {
case 'shop':
aValue = a.shop.name;
bValue = b.shop.name;
break;
case 'date':
// Safely handle date parsing with validation
try {
aValue = new Date(a.date);
bValue = new Date(b.date);
// Check if dates are valid
if (isNaN(aValue.getTime())) aValue = new Date(0); // fallback to epoch
if (isNaN(bValue.getTime())) bValue = new Date(0); // fallback to epoch
} catch (error) {
console.error('Error parsing dates for sorting:', error);
aValue = new Date(0);
bValue = new Date(0);
}
break;
case 'items':
aValue = a.products.length;
bValue = b.products.length;
break;
case 'total':
aValue = a.total_amount || 0;
bValue = b.total_amount || 0;
break;
case 'notes':
aValue = a.notes || '';
bValue = b.notes || '';
break;
default:
aValue = '';
bValue = '';
}
// Handle different data types
if (aValue instanceof Date && bValue instanceof Date) {
if (sortDirection === 'asc') {
return aValue.getTime() - bValue.getTime();
} else {
return bValue.getTime() - aValue.getTime();
}
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
if (sortDirection === 'asc') {
return aValue - bValue;
} else {
return bValue - aValue;
}
} else {
// String comparison
const aStr = String(aValue).toLowerCase();
const bStr = String(bValue).toLowerCase();
if (sortDirection === 'asc') {
return aStr.localeCompare(bStr);
} else {
return bStr.localeCompare(aStr);
}
}
});
const getSortIcon = (field: string) => {
if (sortField !== field) {
return (
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
</svg>
);
}
if (sortDirection === 'asc') {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
);
} else {
return (
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
);
}
};
// Safe date formatting function
const formatDate = (dateString: string): string => {
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return 'Invalid Date';
}
return date.toLocaleDateString();
} catch (error) {
console.error('Error formatting date:', error);
return 'Invalid Date';
}
};
if (loading) { if (loading) {
return ( return (
<div className="flex justify-center items-center h-64"> <div className="flex justify-center items-center h-64">
@@ -34,9 +280,12 @@ const ShoppingEventList: React.FC = () => {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex justify-between items-center"> <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1> <h1 className="text-xl md:text-2xl font-bold text-gray-900">Shopping Events</h1>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"> <button
onClick={() => setIsModalOpen(true)}
className="w-full sm:w-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 sm:py-2 px-4 rounded text-base sm:text-sm"
>
Add New Event Add New Event
</button> </button>
</div> </div>
@@ -57,65 +306,244 @@ const ShoppingEventList: React.FC = () => {
<p className="mt-1 text-sm text-gray-500">Get started by recording your first purchase.</p> <p className="mt-1 text-sm text-gray-500">Get started by recording your first purchase.</p>
</div> </div>
) : ( ) : (
<div className="space-y-4 p-6"> <>
{events.map((event) => ( {/* Desktop Table */}
<div key={event.id} className="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow"> <div className="hidden md:block">
<div className="flex justify-between items-start mb-4"> <table className="min-w-full divide-y divide-gray-200">
<div> <thead className="bg-gray-50">
<h3 className="text-lg font-medium text-gray-900">{event.shop.name}</h3> <tr>
<p className="text-sm text-gray-600">{event.shop.city}</p> <th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('shop')}
>
<div className="flex items-center">
Shop
{getSortIcon('shop')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('date')}
>
<div className="flex items-center">
Date
{getSortIcon('date')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('items')}
>
<div className="flex items-center">
Items
{getSortIcon('items')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('total')}
>
<div className="flex items-center">
Total
{getSortIcon('total')}
</div>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('notes')}
>
<div className="flex items-center">
Notes
{getSortIcon('notes')}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedEvents.map((event) => (
<tr key={event.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">{event.shop.name}</div>
<div className="text-xs text-gray-500">{event.shop.city}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{formatDate(event.date)}
</td>
<td
className={`items-cell px-6 py-4 whitespace-nowrap text-sm ${
event.products.length > 0
? 'text-blue-600 hover:text-blue-800 cursor-pointer hover:bg-blue-50'
: 'text-gray-900'
}`}
onMouseEnter={(e) => handleItemsHover(event, e)}
onMouseLeave={handleItemsLeave}
onClick={(e) => handleItemsClick(event, e)}
title={event.products.length > 0 ? 'Click to view items' : ''}
>
{event.products.length} item{event.products.length !== 1 ? 's' : ''}
{event.products.length > 0 && (
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{event.total_amount ? (
<span className="text-sm font-semibold text-green-600">
${event.total_amount.toFixed(2)}
</span>
) : (
<span className="text-sm text-gray-500">-</span>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{event.notes ? (
<span className="truncate max-w-xs block" title={event.notes}>
{event.notes.length > 30 ? `${event.notes.substring(0, 30)}...` : event.notes}
</span>
) : (
'-'
)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEdit(event)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDelete(event)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile Card Layout */}
<div className="md:hidden">
{sortedEvents.map((event) => (
<div key={event.id} className="border-b border-gray-200 p-4 last:border-b-0">
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">{event.shop.name}</h3>
<p className="text-sm text-gray-500">{event.shop.city}</p>
</div>
<div className="text-right flex-shrink-0 ml-4">
<p className="text-sm text-gray-600">{formatDate(event.date)}</p>
{event.total_amount && (
<p className="font-semibold text-green-600 mt-1">
${event.total_amount.toFixed(2)}
</p>
)}
</div>
</div> </div>
<div className="text-right">
<p className="text-sm font-medium text-gray-900"> <div className="flex items-center justify-between mb-3">
{new Date(event.date).toLocaleDateString()} <button
</p> onClick={(e) => handleItemsClick(event, e)}
{event.total_amount && ( className={`text-sm ${
<p className="text-lg font-semibold text-green-600"> event.products.length > 0
${event.total_amount.toFixed(2)} ? 'text-blue-600 hover:text-blue-800'
: 'text-gray-600'
}`}
disabled={event.products.length === 0}
>
{event.products.length} item{event.products.length !== 1 ? 's' : ''}
{event.products.length > 0 && (
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
</button>
{event.notes && (
<p className="text-sm text-gray-500 truncate max-w-xs" title={event.notes}>
{event.notes.length > 20 ? `${event.notes.substring(0, 20)}...` : event.notes}
</p> </p>
)} )}
</div> </div>
</div>
{event.groceries.length > 0 && ( <div className="flex space-x-4">
<div className="mb-4"> <button
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4> onClick={() => handleEdit(event)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2"> className="flex-1 text-center py-2 px-4 border border-indigo-300 text-indigo-600 hover:bg-indigo-50 rounded-md text-sm font-medium"
{event.groceries.map((grocery) => ( >
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2"> Edit
<span className="text-sm text-gray-900">{grocery.name}</span>
<span className="text-xs text-gray-600 ml-2">${grocery.price}</span>
</div>
))}
</div>
</div>
)}
{event.notes && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-1">Notes:</h4>
<p className="text-sm text-gray-600">{event.notes}</p>
</div>
)}
<div className="flex justify-between items-center text-sm">
<span className="text-gray-500">
Event #{event.id} {new Date(event.created_at).toLocaleDateString()}
</span>
<div className="flex space-x-2">
<button className="text-indigo-600 hover:text-indigo-900">
View Details
</button> </button>
<button className="text-red-600 hover:text-red-900"> <button
onClick={() => handleDelete(event)}
className="flex-1 text-center py-2 px-4 border border-red-300 text-red-600 hover:bg-red-50 rounded-md text-sm font-medium"
>
Delete Delete
</button> </button>
</div> </div>
</div> </div>
))}
</div>
</>
)}
</div>
{/* Items Popup */}
{showItemsPopup && hoveredEvent && (
<div
className="items-popup fixed bg-white border rounded-lg shadow-lg p-4 z-50 max-w-md max-h-64 overflow-y-auto"
style={{
left: window.innerWidth < 768 ? '50%' : `${popupPosition.x}px`,
top: window.innerWidth < 768 ? '50%' : `${popupPosition.y}px`,
transform: window.innerWidth < 768 ? 'translate(-50%, -50%)' : 'none'
}}
>
<div className="flex justify-between items-center mb-2">
<h4 className="font-medium text-gray-900">Items Purchased</h4>
<button
onClick={() => setShowItemsPopup(false)}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-4 h-4" 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>
<div className="space-y-2">
{hoveredEvent.products.map((product, index) => (
<div key={index} className="text-sm">
<div className="font-medium text-gray-900">{product.name}</div>
<div className="text-gray-600">
{product.amount} × ${product.price.toFixed(2)} = ${(product.amount * product.price).toFixed(2)}
{product.discount && <span className="ml-2 text-green-600">🏷</span>}
</div>
</div> </div>
))} ))}
</div> </div>
)} </div>
</div> )}
{/* Modals */}
<ConfirmDeleteModal
isOpen={!!deletingEvent}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Shopping Event"
message={`Are you sure you want to delete the shopping event at ${deletingEvent?.shop.name}? This action cannot be undone.`}
isLoading={deleteLoading}
/>
<AddShoppingEventModal
isOpen={isModalOpen}
onClose={handleCloseModal}
onEventAdded={handleEventAdded}
editEvent={editingEvent}
/>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,7 @@
import packageJson from '../../package.json';
export const VERSION = {
frontend: packageJson.version,
buildDate: new Date().toISOString().split('T')[0], // YYYY-MM-DD format
name: "Groceries Tracker"
};

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
export const useBodyScrollLock = (isLocked: boolean) => {
useEffect(() => {
if (!isLocked) return;
// Store original body overflow and position
const originalOverflow = document.body.style.overflow;
const originalPosition = document.body.style.position;
const originalTop = document.body.style.top;
const originalWidth = document.body.style.width;
// Get current scroll position
const scrollY = window.scrollY;
// Lock the body scroll
document.body.style.overflow = 'hidden';
document.body.style.position = 'fixed';
document.body.style.top = `-${scrollY}px`;
document.body.style.width = '100%';
// Cleanup function to restore original styles
return () => {
document.body.style.overflow = originalOverflow;
document.body.style.position = originalPosition;
document.body.style.top = originalTop;
document.body.style.width = originalWidth;
// Restore scroll position
window.scrollTo(0, scrollY);
};
}, [isLocked]);
};

View File

@@ -0,0 +1,26 @@
import { useState, useEffect } from 'react';
export const useIsMobile = (breakpoint: number = 768): boolean => {
const [isMobile, setIsMobile] = useState<boolean>(false);
useEffect(() => {
const checkIsMobile = () => {
setIsMobile(window.innerWidth < breakpoint);
};
// Check on mount
checkIsMobile();
// Add event listener
window.addEventListener('resize', checkIsMobile);
// Cleanup
return () => {
window.removeEventListener('resize', checkIsMobile);
};
}, [breakpoint]);
return isMobile;
};
export default useIsMobile;

View File

@@ -1,23 +1,39 @@
import axios from 'axios'; import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, GroceryCategory, GroceryCategoryCreate, BrandInShop, BrandInShopCreate } from '../types';
import { Grocery, GroceryCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
const BASE_URL = 'http://localhost:8000'; // Use different API URLs based on environment
const API_BASE_URL = process.env.NODE_ENV === 'production'
? '/api' // Use nginx proxy in production
: 'http://localhost:8000'; // Direct backend connection in development
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: (showDeleted: boolean = false) => api.get<Product[]>(`/products/?show_deleted=${showDeleted}`),
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`), getById: (id: number) => api.get<Product>(`/products/${id}`),
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery), getValidFromDate: (id: number) => api.get<{ valid_from: string }>(`/products/${id}/valid-from`),
update: (id: number, grocery: Partial<GroceryCreate>) => getAvailableForShopping: (shoppingDate: string) => api.get<Product[]>(`/products/available-for-shopping/${shoppingDate}`),
api.put<Grocery>(`/groceries/${id}`, grocery), create: (product: ProductCreate) => api.post<Product>('/products/', product),
delete: (id: number) => api.delete(`/groceries/${id}`), update: (id: number, product: Partial<ProductCreate>) =>
api.put<Product>(`/products/${id}`, product),
delete: (id: number) => api.delete(`/products/${id}`),
}; };
// Shop API functions // Shop API functions
@@ -30,13 +46,41 @@ 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}`),
};
// BrandInShop API functions
export const brandInShopApi = {
getAll: () => api.get<BrandInShop[]>('/brands-in-shops/'),
getByShop: (shopId: number) => api.get<BrandInShop[]>(`/brands-in-shops/shop/${shopId}`),
getByBrand: (brandId: number) => api.get<BrandInShop[]>(`/brands-in-shops/brand/${brandId}`),
getById: (id: number) => api.get<BrandInShop>(`/brands-in-shops/${id}`),
create: (brandInShop: BrandInShopCreate) => api.post<BrandInShop>('/brands-in-shops/', brandInShop),
delete: (id: number) => api.delete(`/brands-in-shops/${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: Partial<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}`),
}; };
@@ -47,4 +91,9 @@ export const statsApi = {
getShops: () => api.get('/stats/shops'), getShops: () => api.get('/stats/shops'),
}; };
// Utility API functions
export const utilityApi = {
getCurrentDate: () => api.get<{ current_date: string }>('/current-date'),
};
export default api; export default api;

View File

@@ -1,22 +1,48 @@
export interface Grocery { export interface Brand {
id: number; id: number;
name: string; name: string;
price: number;
category: string;
organic: boolean;
weight?: number;
weight_unit: string;
created_at: string; created_at: string;
updated_at?: string; updated_at?: string;
} }
export interface GroceryCreate { export interface BrandCreate {
name: string; name: string;
price: number; }
category: string;
export interface GroceryCategory {
id: number;
name: string;
created_at: string;
updated_at?: string;
}
export interface GroceryCategoryCreate {
name: string;
}
export interface Product {
id: number;
name: string;
category_id: number;
category: GroceryCategory;
brand_id?: number;
brand?: Brand;
organic: boolean; organic: boolean;
weight?: number; weight?: number;
weight_unit: string; weight_unit: string;
deleted?: boolean;
created_at: string;
updated_at?: string;
}
export interface ProductCreate {
name: string;
category_id: number;
brand_id?: number;
organic: boolean;
weight?: number;
weight_unit: string;
valid_from?: string; // Optional: ISO date string (YYYY-MM-DD), defaults to current date if not provided
} }
export interface Shop { export interface Shop {
@@ -25,17 +51,33 @@ 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 {
name: string; name: string;
city: string; city: string;
address?: string; address?: string | null;
} }
export interface GroceryInEvent { export interface ProductInEvent {
grocery_id: number; product_id: number;
amount: number; amount: number;
price: number;
discount: boolean;
}
export interface ProductWithEventData {
id: number;
name: string;
category: GroceryCategory;
brand?: Brand;
organic: boolean;
weight?: number;
weight_unit: string;
amount: number;
price: number;
discount: boolean;
} }
export interface ShoppingEvent { export interface ShoppingEvent {
@@ -45,8 +87,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: Grocery[]; products: ProductWithEventData[];
} }
export interface ShoppingEventCreate { export interface ShoppingEventCreate {
@@ -54,7 +97,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 {
@@ -70,3 +113,18 @@ export interface ShopStats {
visit_count: number; visit_count: number;
avg_per_visit: number; avg_per_visit: number;
} }
export interface BrandInShop {
id: number;
shop_id: number;
brand_id: number;
created_at: string;
updated_at?: string;
shop: Shop;
brand: Brand;
}
export interface BrandInShopCreate {
shop_id: number;
brand_id: number;
}

View File

@@ -0,0 +1,102 @@
/**
* Date utility functions for safe date handling throughout the application
*/
/**
* Safely formats a date string to a localized date string
* @param dateString - The date string to format
* @param fallback - The fallback value if the date is invalid (default: 'Invalid Date')
* @returns Formatted date string or fallback value
*/
export const formatDate = (dateString: string | null | undefined, fallback: string = 'Invalid Date'): string => {
if (!dateString) return fallback;
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return fallback;
}
return date.toLocaleDateString();
} catch (error) {
console.error('Error formatting date:', error);
return fallback;
}
};
/**
* Safely creates a Date object from a string
* @param dateString - The date string to parse
* @returns Date object or null if invalid
*/
export const safeParseDate = (dateString: string | null | undefined): Date | null => {
if (!dateString) return null;
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) {
return null;
}
return date;
} catch (error) {
console.error('Error parsing date:', error);
return null;
}
};
/**
* Safely compares two dates for sorting
* @param dateA - First date string
* @param dateB - Second date string
* @param direction - Sort direction ('asc' or 'desc')
* @returns Comparison result (-1, 0, 1)
*/
export const compareDates = (
dateA: string | null | undefined,
dateB: string | null | undefined,
direction: 'asc' | 'desc' = 'asc'
): number => {
const parsedA = safeParseDate(dateA);
const parsedB = safeParseDate(dateB);
// Handle null/invalid dates
if (!parsedA && !parsedB) return 0;
if (!parsedA) return direction === 'asc' ? 1 : -1;
if (!parsedB) return direction === 'asc' ? -1 : 1;
const result = parsedA.getTime() - parsedB.getTime();
return direction === 'asc' ? result : -result;
};
/**
* Gets the current date in YYYY-MM-DD format
* @returns Current date string
*/
export const getCurrentDateString = (): string => {
return new Date().toISOString().split('T')[0];
};
/**
* Validates if a date string is valid and not in the future
* @param dateString - The date string to validate
* @param allowFuture - Whether to allow future dates (default: false)
* @returns Object with validation result and error message
*/
export const validateDate = (
dateString: string | null | undefined,
allowFuture: boolean = false
): { isValid: boolean; error?: string } => {
if (!dateString) {
return { isValid: false, error: 'Date is required' };
}
const date = safeParseDate(dateString);
if (!date) {
return { isValid: false, error: 'Invalid date format' };
}
if (!allowFuture && date > new Date()) {
return { isValid: false, error: 'Date cannot be in the future' };
}
return { isValid: true };
};

11
init-db.sql Normal file
View File

@@ -0,0 +1,11 @@
-- Optional database initialization script
-- This file will be executed when the PostgreSQL container starts for the first time
-- Create extensions if needed
-- CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- You can add any initial database setup here
-- For example, creating additional users, setting permissions, etc.
-- Note: Your FastAPI app with Alembic will handle the actual table creation
-- This file is mainly for any PostgreSQL-specific setup

View File

@@ -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": ""
}
} }

127
test_temporal_logic.md Normal file
View File

@@ -0,0 +1,127 @@
# Temporal Logic Test Scenarios
## Test Case 1: Add New Product
### Frontend Behavior:
- **Default valid_from**: Today's date (e.g., 2025-01-15)
- **User constraint**: Can edit to any date <= today
- **Date picker**: max="2025-01-15", no min restriction
### API Call:
```json
POST /products/
{
"name": "New Milk",
"category_id": 1,
"weight": 1000,
"valid_from": "2025-01-10" // User chose 5 days ago
}
```
### Backend Validation:
- ✅ valid_from <= today (2025-01-10 <= 2025-01-15)
### Database Result:
```sql
products: id=1, name="New Milk", weight=1000, valid_from='2025-01-10', valid_to='9999-12-31'
```
---
## Test Case 2: Edit Existing Product
### Current State:
```sql
products: id=1, name="Milk", weight=1000, valid_from='2025-01-10', valid_to='9999-12-31'
```
### Frontend Behavior:
- **Fetch current valid_from**: API call to `/products/1/valid-from` → "2025-01-10"
- **Default valid_from**: Today's date (2025-01-15)
- **User constraint**: Can edit to any date > 2025-01-10 AND <= today
- **Date picker**: min="2025-01-10", max="2025-01-15"
### API Call:
```json
PUT /products/1
{
"weight": 1200,
"valid_from": "2025-01-12" // User chose 2 days after current valid_from
}
```
### Backend Validation:
- ✅ valid_from <= today (2025-01-12 <= 2025-01-15)
- ✅ valid_from > current_valid_from (2025-01-12 > 2025-01-10)
### Backend Processing:
1. **Manual versioning** (since valid_from was specified):
- Create history record with old data
- Set history.valid_to = new.valid_from
### Database Result:
```sql
-- History table gets the old version
products_history:
id=1, name="Milk", weight=1000,
valid_from='2025-01-10', valid_to='2025-01-12', operation='U'
-- Current table gets updated version
products:
id=1, name="Milk", weight=1200,
valid_from='2025-01-12', valid_to='9999-12-31'
```
---
## Test Case 3: Edit Without Custom Date (Automatic Versioning)
### Current State:
```sql
products: id=1, name="Milk", weight=1200, valid_from='2025-01-12', valid_to='9999-12-31'
```
### API Call (no valid_from specified):
```json
PUT /products/1
{
"name": "Organic Milk"
}
```
### Backend Processing:
1. **Automatic versioning** (trigger handles it):
- Trigger detects OLD.valid_from = NEW.valid_from
- Trigger creates history with valid_to = CURRENT_DATE
- Trigger sets NEW.valid_from = CURRENT_DATE
### Database Result:
```sql
-- History table gets the old version (trigger created)
products_history:
id=1, name="Milk", weight=1200,
valid_from='2025-01-12', valid_to='2025-01-15', operation='U'
-- Current table gets updated version (trigger set dates)
products:
id=1, name="Organic Milk", weight=1200,
valid_from='2025-01-15', valid_to='9999-12-31'
```
---
## Validation Rules Summary
### For New Products:
- ✅ Frontend: valid_from <= today
- ✅ Backend: valid_from <= today
### For Existing Products:
- ✅ Frontend: current_valid_from < valid_from <= today
- ✅ Backend: current_valid_from < valid_from <= today
- ✅ Manual versioning: history.valid_to = new.valid_from
- ✅ Automatic versioning: history.valid_to = CURRENT_DATE
### Database Trigger Logic:
- **Manual versioning**: When OLD.valid_from ≠ NEW.valid_from (app handles history)
- **Automatic versioning**: When OLD.valid_from = NEW.valid_from (trigger handles history)