Compare commits
26 Commits
f88a931008
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fe45ad63b | |||
| 9af2fa5c7f | |||
| 3e9ad2dcb1 | |||
| df8209e86d | |||
| fa730b3b8e | |||
| 0b42a74fe9 | |||
| 56c3c16f6d | |||
| 1a8c0587ee | |||
| 2afa7dbebf | |||
| 521a0d6937 | |||
| 69a0872029 | |||
| 87033d7f9a | |||
| eb3ae05425 | |||
| 330124837f | |||
| 666ce5d4d4 | |||
| 112ea41e88 | |||
| 3ea5db4214 | |||
| 03d80b99dc | |||
| 2846bcbb1c | |||
| 7037be370e | |||
| 629a89524c | |||
| e20d0f0524 | |||
| a97554bf32 | |||
| 81575e401d | |||
| f674c19a67 | |||
| b68f1b51ce |
7
.gitignore
vendored
7
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
313
README.md
313
README.md
@@ -2,14 +2,64 @@
|
|||||||
|
|
||||||
A web application for tracking product 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
|
||||||
|
|
||||||
- **Product Management**: Add, edit, and track product items with prices, categories, and organic status
|
- **Product Management**: Add, edit, and track product items with prices, categories, and organic status
|
||||||
- **Shop Management**: Manage different shops with locations
|
- **Shop Management**: Manage different shops with locations
|
||||||
|
- **Brand Management**: Track product brands and their availability in different shops
|
||||||
- **Shopping Events**: Record purchases with multiple products and amounts
|
- **Shopping Events**: Record purchases with multiple products and amounts
|
||||||
- **Price Tracking**: Monitor price changes over time
|
- **Price Tracking**: Monitor price changes over time
|
||||||
|
- **Import/Export**: Bulk import and export data via CSV files
|
||||||
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
|
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
|
||||||
|
|
||||||
|
## Quick Start with Docker
|
||||||
|
|
||||||
|
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 product 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,7 +88,7 @@ A web application for tracking product 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) │
|
||||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -42,14 +96,27 @@ A web application for tracking product prices and shopping events. Built with Fa
|
|||||||
|
|
||||||
### Core Entities
|
### Core Entities
|
||||||
|
|
||||||
|
#### Brands (`brands` table)
|
||||||
|
- `id`: Integer, Primary key, Auto-increment
|
||||||
|
- `name`: String, Brand name (indexed, required)
|
||||||
|
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
||||||
|
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
||||||
|
|
||||||
|
#### Grocery Categories (`grocery_categories` table)
|
||||||
|
- `id`: Integer, Primary key, Auto-increment
|
||||||
|
- `name`: String, Category name (indexed, required)
|
||||||
|
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
||||||
|
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
||||||
|
|
||||||
#### Products (`products` table)
|
#### Products (`products` table)
|
||||||
- `id`: Integer, Primary key, Auto-increment
|
- `id`: Integer, Primary key, Auto-increment
|
||||||
- `name`: String, Product name (indexed, required)
|
- `name`: String, Product name (indexed, required)
|
||||||
- `category`: String, Food category (required)
|
- `category_id`: Integer, Foreign key to grocery_categories (required)
|
||||||
|
- `brand_id`: Integer, Foreign key to brands (optional)
|
||||||
- `organic`: Boolean, Organic flag (default: false)
|
- `organic`: Boolean, Organic flag (default: false)
|
||||||
- `weight`: Float, Weight/volume (optional)
|
- `weight`: Float, Weight/volume (optional)
|
||||||
- `weight_unit`: String, Unit of measurement (default: "piece")
|
- `weight_unit`: String, Unit of measurement (default: "piece")
|
||||||
- Supported units: "g", "kg", "ml", "l", "piece"
|
- Supported units: "g", "kg", "lb", "oz", "ml", "l", "piece"
|
||||||
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
||||||
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
||||||
|
|
||||||
@@ -70,7 +137,15 @@ A web application for tracking product prices and shopping events. Built with Fa
|
|||||||
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
- `created_at`: DateTime, Creation timestamp (auto-generated)
|
||||||
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
- `updated_at`: DateTime, Last update timestamp (auto-updated)
|
||||||
|
|
||||||
### Association Table
|
#### 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)
|
#### Shopping Event Products (`shopping_event_products` table)
|
||||||
Many-to-many relationship between shopping events and products with additional data:
|
Many-to-many relationship between shopping events and products with additional data:
|
||||||
@@ -79,45 +154,105 @@ Many-to-many relationship between shopping events and products with additional d
|
|||||||
- `product_id`: Integer, Foreign key to products (required)
|
- `product_id`: Integer, Foreign key to products (required)
|
||||||
- `amount`: Float, Quantity purchased in this event (required, > 0)
|
- `amount`: Float, Quantity purchased in this event (required, > 0)
|
||||||
- `price`: Float, Price at time of purchase (required, ≥ 0)
|
- `price`: Float, Price at time of purchase (required, ≥ 0)
|
||||||
|
- `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
|
### Relationships
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
|
┌─────────────────┐ 1:N ┌─────────────────┐ 1:N ┌─────────────────┐
|
||||||
│ Shops │ │ Shopping Event Products │ │ Products │
|
│ Brands │ ────────→ │ Products │ ←──────── │ Grocery │
|
||||||
│ │ │ (Association Table) │ │ │
|
│ │ │ │ │ Categories │
|
||||||
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
|
│ • id │ │ • id │ │ • id │
|
||||||
│ • name │ 1:N │ • product_id │ N:M │ • name │
|
│ • name │ │ • name │ │ • name │
|
||||||
│ • city │ │ • amount │ │ • category │
|
│ • created_at │ │ • category_id │ │ • created_at │
|
||||||
│ • address │ │ • price │ │ • organic │
|
│ • updated_at │ │ • brand_id │ │ • updated_at │
|
||||||
│ • created_at │ │ │ │ • weight │
|
└─────────────────┘ │ • organic │ └─────────────────┘
|
||||||
└─────────────────┘ └─────────────────────────────┘ │ • weight_unit │
|
│ │ • 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 │
|
│ │ │ • created_at │
|
||||||
│ │ │ • updated_at │
|
│ │ └─────────────────────────────┘
|
||||||
│ 1:N │ └─────────────────┘
|
│ │
|
||||||
▼ │
|
│ │ N:M
|
||||||
┌─────────────────┐ │
|
│ ▼
|
||||||
│ Shopping Events │ ←───────────────────────┘
|
│ ┌─────────────────────────────┐
|
||||||
│ │ 1:N
|
│ │ Shopping Event Products │
|
||||||
│ • id │
|
│ │ (Association Table) │
|
||||||
│ • shop_id │
|
│ │ │
|
||||||
│ • date │
|
│ │ • id │
|
||||||
│ • total_amount │
|
│ │ • shopping_event_id │
|
||||||
│ • notes │
|
│ │ • product_id │
|
||||||
│ • created_at │
|
│ │ • 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
|
### 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
|
- **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)
|
- **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples)
|
||||||
- **Auto-calculation**: Total amount can be automatically calculated from individual items
|
- **Auto-calculation**: Total amount can be automatically calculated from individual items
|
||||||
- **Free Items**: Supports items with price 0 (samples, promotions, etc.)
|
- **Free Items**: Supports items with price 0 (samples, promotions, etc.)
|
||||||
|
- **Shop-Brand Filtering**: Products can be filtered by brands available in specific shops
|
||||||
- **Audit Trail**: All entities have creation timestamps for tracking
|
- **Audit Trail**: All entities have creation timestamps for tracking
|
||||||
- **Data Integrity**: Foreign key constraints ensure referential integrity
|
- **Data Integrity**: Foreign key constraints ensure referential integrity
|
||||||
|
- **Import/Export**: CSV-based bulk data operations for all entity types
|
||||||
|
|
||||||
## Setup Instructions
|
## Development Setup
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
@@ -189,6 +324,20 @@ Many-to-many relationship between shopping events and products with additional d
|
|||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
|
### Brands
|
||||||
|
- `GET /brands/` - List all brands
|
||||||
|
- `POST /brands/` - Create new brand
|
||||||
|
- `GET /brands/{id}` - Get specific brand
|
||||||
|
- `PUT /brands/{id}` - Update brand
|
||||||
|
- `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
|
### Products
|
||||||
- `GET /products/` - List all products
|
- `GET /products/` - List all products
|
||||||
- `POST /products/` - Create new product
|
- `POST /products/` - Create new product
|
||||||
@@ -200,11 +349,31 @@ Many-to-many relationship between shopping events and products with additional d
|
|||||||
- `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
|
||||||
@@ -213,10 +382,50 @@ Many-to-many relationship between shopping events and products with additional d
|
|||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
1. **Add Shops**: Start by adding shops where you buy products
|
1. **Add Shops**: Start by adding shops where you buy products
|
||||||
2. **Add Products**: Create product items with prices and categories
|
2. **Add Categories**: Create grocery categories (e.g., "Dairy", "Produce", "Meat")
|
||||||
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
|
3. **Add Brands**: Create brands for your products (optional)
|
||||||
4. **Track Prices**: Monitor how prices change over time
|
4. **Configure Shop-Brand Availability**: Associate brands with shops where they're available
|
||||||
5. **View Statistics**: Analyze spending patterns by category and shop
|
5. **Add Products**: Create product items linked directly to categories and optionally to brands
|
||||||
|
6. **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
|
||||||
|
|
||||||
@@ -242,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: product_tracker
|
|
||||||
POSTGRES_USER: product_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://product_user:your_password@db:5432/product_tracker
|
|
||||||
|
|
||||||
frontend:
|
|
||||||
build: ./frontend
|
|
||||||
ports:
|
|
||||||
- "3000:3000"
|
|
||||||
depends_on:
|
|
||||||
- backend
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
postgres_data:
|
|
||||||
```
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository
|
||||||
|
|||||||
271
SOFT_DELETE_IMPLEMENTATION.md
Normal file
271
SOFT_DELETE_IMPLEMENTATION.md
Normal 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
111
TEMPORAL_FEATURES.md
Normal 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)
|
||||||
88
backend/database_init.py
Normal file
88
backend/database_init.py
Normal 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()
|
||||||
603
backend/main.py
603
backend/main.py
@@ -1,18 +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
|
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="Product Tracker API",
|
title=__app_name__,
|
||||||
description="API for tracking product prices and shopping events",
|
description=__description__,
|
||||||
version="1.0.0"
|
version=__version__
|
||||||
)
|
)
|
||||||
|
|
||||||
# CORS middleware for React frontend
|
# CORS middleware for React frontend
|
||||||
@@ -26,21 +27,18 @@ app.add_middleware(
|
|||||||
|
|
||||||
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
||||||
"""Build a shopping event response with products from the association table"""
|
"""Build a shopping event response with products from the association table"""
|
||||||
# Get products with their event-specific data including grocery and brand information
|
# Get products with their event-specific data including category and brand information
|
||||||
product_data = db.execute(
|
product_data = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
|
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
|
||||||
sep.amount, sep.price,
|
sep.amount, sep.price, sep.discount,
|
||||||
g.id as grocery_id, g.name as grocery_name,
|
|
||||||
g.created_at as grocery_created_at, g.updated_at as grocery_updated_at,
|
|
||||||
gc.id as category_id, gc.name as category_name,
|
gc.id as category_id, gc.name as category_name,
|
||||||
gc.created_at as category_created_at, gc.updated_at as category_updated_at,
|
gc.created_at as category_created_at, gc.updated_at as category_updated_at,
|
||||||
b.id as brand_id, b.name as brand_name,
|
b.id as brand_id, b.name as brand_name,
|
||||||
b.created_at as brand_created_at, b.updated_at as brand_updated_at
|
b.created_at as brand_created_at, b.updated_at as brand_updated_at
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN shopping_event_products sep ON p.id = sep.product_id
|
JOIN shopping_event_products sep ON p.id = sep.product_id
|
||||||
JOIN groceries g ON p.grocery_id = g.id
|
JOIN grocery_categories gc ON p.category_id = gc.id
|
||||||
JOIN grocery_categories gc ON g.category_id = gc.id
|
|
||||||
LEFT JOIN brands b ON p.brand_id = b.id
|
LEFT JOIN brands b ON p.brand_id = b.id
|
||||||
WHERE sep.shopping_event_id = :event_id
|
WHERE sep.shopping_event_id = :event_id
|
||||||
"""),
|
"""),
|
||||||
@@ -57,15 +55,6 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
|||||||
updated_at=row.category_updated_at
|
updated_at=row.category_updated_at
|
||||||
)
|
)
|
||||||
|
|
||||||
grocery = schemas.Grocery(
|
|
||||||
id=row.grocery_id,
|
|
||||||
name=row.grocery_name,
|
|
||||||
category_id=row.category_id,
|
|
||||||
created_at=row.grocery_created_at,
|
|
||||||
updated_at=row.grocery_updated_at,
|
|
||||||
category=category
|
|
||||||
)
|
|
||||||
|
|
||||||
brand = None
|
brand = None
|
||||||
if row.brand_id is not None:
|
if row.brand_id is not None:
|
||||||
brand = schemas.Brand(
|
brand = schemas.Brand(
|
||||||
@@ -79,13 +68,14 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
|||||||
schemas.ProductWithEventData(
|
schemas.ProductWithEventData(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
grocery=grocery,
|
category=category,
|
||||||
brand=brand,
|
brand=brand,
|
||||||
organic=row.organic,
|
organic=row.organic,
|
||||||
weight=row.weight,
|
weight=row.weight,
|
||||||
weight_unit=row.weight_unit,
|
weight_unit=row.weight_unit,
|
||||||
amount=row.amount,
|
amount=row.amount,
|
||||||
price=row.price
|
price=row.price,
|
||||||
|
discount=row.discount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -103,15 +93,43 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
|||||||
# Root endpoint
|
# Root endpoint
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
return {"message": "Product Tracker API", "version": "1.0.0"}
|
return {"message": __app_name__, "version": __version__, "name": "Groceries Tracker Backend"}
|
||||||
|
|
||||||
|
# Utility endpoints
|
||||||
|
@app.get("/current-date")
|
||||||
|
def get_current_date():
|
||||||
|
"""Get current date for use as default in valid_from fields"""
|
||||||
|
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
|
# Product endpoints
|
||||||
@app.post("/products/", response_model=schemas.Product)
|
@app.post("/products/", response_model=schemas.Product)
|
||||||
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
||||||
# Validate grocery exists
|
# Validate category exists
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == product.grocery_id).first()
|
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == product.category_id).first()
|
||||||
if grocery is None:
|
if category is None:
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
# Validate brand exists if brand_id is provided
|
# Validate brand exists if brand_id is provided
|
||||||
if product.brand_id is not None:
|
if product.brand_id is not None:
|
||||||
@@ -119,15 +137,31 @@ def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)
|
|||||||
if brand is None:
|
if brand is None:
|
||||||
raise HTTPException(status_code=404, detail="Brand not found")
|
raise HTTPException(status_code=404, detail="Brand not found")
|
||||||
|
|
||||||
db_product = models.Product(**product.dict())
|
# 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.add(db_product)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_product)
|
db.refresh(db_product)
|
||||||
return db_product
|
return db_product
|
||||||
|
|
||||||
@app.get("/products/", response_model=List[schemas.Product])
|
@app.get("/products/", response_model=List[schemas.Product])
|
||||||
def read_products(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)):
|
||||||
products = db.query(models.Product).offset(skip).limit(limit).all()
|
query = db.query(models.Product)
|
||||||
|
if not show_deleted:
|
||||||
|
query = query.filter(models.Product.deleted == False)
|
||||||
|
products = query.offset(skip).limit(limit).all()
|
||||||
return products
|
return products
|
||||||
|
|
||||||
@app.get("/products/{product_id}", response_model=schemas.Product)
|
@app.get("/products/{product_id}", response_model=schemas.Product)
|
||||||
@@ -137,19 +171,27 @@ def read_product(product_id: int, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
return product
|
return product
|
||||||
|
|
||||||
|
@app.get("/products/{product_id}/valid-from")
|
||||||
|
def get_product_valid_from(product_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""Get the current valid_from date for a product (used for validation when editing)"""
|
||||||
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
return {"valid_from": product.valid_from.isoformat()}
|
||||||
|
|
||||||
@app.put("/products/{product_id}", response_model=schemas.Product)
|
@app.put("/products/{product_id}", response_model=schemas.Product)
|
||||||
def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
|
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()
|
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
update_data = product_update.dict(exclude_unset=True)
|
update_data = product_update.dict(exclude_unset=True, exclude={'valid_from'})
|
||||||
|
|
||||||
# Validate grocery exists if grocery_id is being updated
|
# Validate category exists if category_id is being updated
|
||||||
if 'grocery_id' in update_data:
|
if 'category_id' in update_data:
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == update_data['grocery_id']).first()
|
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == update_data['category_id']).first()
|
||||||
if grocery is None:
|
if category is None:
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
raise HTTPException(status_code=404, detail="Category not found")
|
||||||
|
|
||||||
# Validate brand exists if brand_id is being updated
|
# Validate brand exists if brand_id is being updated
|
||||||
if 'brand_id' in update_data and update_data['brand_id'] is not None:
|
if 'brand_id' in update_data and update_data['brand_id'] is not None:
|
||||||
@@ -157,9 +199,32 @@ def update_product(product_id: int, product_update: schemas.ProductUpdate, db: S
|
|||||||
if brand is None:
|
if brand is None:
|
||||||
raise HTTPException(status_code=404, detail="Brand not found")
|
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(product, 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(product)
|
db.refresh(product)
|
||||||
return product
|
return product
|
||||||
@@ -170,10 +235,163 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
|
|||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
db.delete(product)
|
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.commit()
|
db.commit()
|
||||||
return {"message": "Product 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)
|
||||||
def create_shop(shop: schemas.ShopCreate, db: Session = Depends(get_db)):
|
def create_shop(shop: schemas.ShopCreate, db: Session = Depends(get_db)):
|
||||||
@@ -272,6 +490,75 @@ def delete_brand(brand_id: int, db: Session = Depends(get_db)):
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Brand deleted successfully"}
|
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
|
# Grocery Category endpoints
|
||||||
@app.post("/grocery-categories/", response_model=schemas.GroceryCategory)
|
@app.post("/grocery-categories/", response_model=schemas.GroceryCategory)
|
||||||
def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)):
|
def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)):
|
||||||
@@ -313,83 +600,18 @@ def delete_grocery_category(category_id: int, db: Session = Depends(get_db)):
|
|||||||
if category is None:
|
if category is None:
|
||||||
raise HTTPException(status_code=404, detail="Grocery category not found")
|
raise HTTPException(status_code=404, detail="Grocery category not found")
|
||||||
|
|
||||||
# Check if any groceries reference this category
|
# Check if any products reference this category
|
||||||
groceries_with_category = db.query(models.Grocery).filter(models.Grocery.category_id == category_id).first()
|
products_with_category = db.query(models.Product).filter(models.Product.category_id == category_id).first()
|
||||||
if groceries_with_category:
|
if products_with_category:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Cannot delete category: groceries are still associated with this category"
|
detail="Cannot delete category: products are still associated with this category"
|
||||||
)
|
)
|
||||||
|
|
||||||
db.delete(category)
|
db.delete(category)
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Grocery category deleted successfully"}
|
return {"message": "Grocery category deleted successfully"}
|
||||||
|
|
||||||
# Grocery endpoints
|
|
||||||
@app.post("/groceries/", response_model=schemas.Grocery)
|
|
||||||
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
|
|
||||||
# Validate category exists
|
|
||||||
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == grocery.category_id).first()
|
|
||||||
if category is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Grocery category not found")
|
|
||||||
|
|
||||||
db_grocery = models.Grocery(**grocery.dict())
|
|
||||||
db.add(db_grocery)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(db_grocery)
|
|
||||||
return db_grocery
|
|
||||||
|
|
||||||
@app.get("/groceries/", response_model=List[schemas.Grocery])
|
|
||||||
def read_groceries(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
|
||||||
groceries = db.query(models.Grocery).offset(skip).limit(limit).all()
|
|
||||||
return groceries
|
|
||||||
|
|
||||||
@app.get("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
|
||||||
def read_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
|
||||||
if grocery is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
|
||||||
return grocery
|
|
||||||
|
|
||||||
@app.put("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
|
||||||
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)):
|
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
|
||||||
if grocery is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
|
||||||
|
|
||||||
update_data = grocery_update.dict(exclude_unset=True)
|
|
||||||
|
|
||||||
# Validate category exists if category_id is being updated
|
|
||||||
if 'category_id' in update_data:
|
|
||||||
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == update_data['category_id']).first()
|
|
||||||
if category is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Grocery category not found")
|
|
||||||
|
|
||||||
for field, value in update_data.items():
|
|
||||||
setattr(grocery, field, value)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
db.refresh(grocery)
|
|
||||||
return grocery
|
|
||||||
|
|
||||||
@app.delete("/groceries/{grocery_id}")
|
|
||||||
def delete_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
|
||||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
|
||||||
if grocery is None:
|
|
||||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
|
||||||
|
|
||||||
# Check if any products reference this grocery
|
|
||||||
products_with_grocery = db.query(models.Product).filter(models.Product.grocery_id == grocery_id).first()
|
|
||||||
if products_with_grocery:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail="Cannot delete grocery: products are still associated with this grocery"
|
|
||||||
)
|
|
||||||
|
|
||||||
db.delete(grocery)
|
|
||||||
db.commit()
|
|
||||||
return {"message": "Grocery deleted successfully"}
|
|
||||||
|
|
||||||
# Shopping Event endpoints
|
# Shopping Event endpoints
|
||||||
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
||||||
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
||||||
@@ -421,7 +643,8 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
|
|||||||
shopping_event_id=db_event.id,
|
shopping_event_id=db_event.id,
|
||||||
product_id=product_item.product_id,
|
product_id=product_item.product_id,
|
||||||
amount=product_item.amount,
|
amount=product_item.amount,
|
||||||
price=product_item.price
|
price=product_item.price,
|
||||||
|
discount=product_item.discount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -478,7 +701,8 @@ def update_shopping_event(event_id: int, event_update: schemas.ShoppingEventCrea
|
|||||||
shopping_event_id=event_id,
|
shopping_event_id=event_id,
|
||||||
product_id=product_item.product_id,
|
product_id=product_item.product_id,
|
||||||
amount=product_item.amount,
|
amount=product_item.amount,
|
||||||
price=product_item.price
|
price=product_item.price,
|
||||||
|
discount=product_item.discount
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -515,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)
|
||||||
@@ -1,11 +1,89 @@
|
|||||||
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()
|
||||||
|
|
||||||
|
# Trigger creation SQL
|
||||||
|
PRODUCTS_VERSIONING_TRIGGER_SQL = """
|
||||||
|
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
|
# Association table for many-to-many relationship between shopping events and products
|
||||||
shopping_event_products = Table(
|
shopping_event_products = Table(
|
||||||
'shopping_event_products',
|
'shopping_event_products',
|
||||||
@@ -14,9 +92,34 @@ shopping_event_products = Table(
|
|||||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
|
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
|
||||||
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
|
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
|
||||||
Column('amount', Float, nullable=False), # Amount of this product bought in this event
|
Column('amount', Float, nullable=False), # Amount of this product bought in this event
|
||||||
Column('price', Float, nullable=False) # Price of this product at the time of this shopping 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Association table for many-to-many self-referential relationship between related products
|
||||||
|
related_products = Table(
|
||||||
|
'related_products',
|
||||||
|
Base.metadata,
|
||||||
|
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
|
||||||
|
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
|
||||||
|
Column('related_product_id', Integer, ForeignKey('products.id'), nullable=False),
|
||||||
|
Column('relationship_type', String, nullable=True), # Optional: e.g., "size_variant", "brand_variant", "similar"
|
||||||
|
Column('created_at', DateTime(timezone=True), server_default=func.now())
|
||||||
|
)
|
||||||
|
|
||||||
|
class BrandInShop(Base):
|
||||||
|
__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):
|
class Brand(Base):
|
||||||
__tablename__ = "brands"
|
__tablename__ = "brands"
|
||||||
|
|
||||||
@@ -27,6 +130,7 @@ class Brand(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
products = relationship("Product", back_populates="brand")
|
products = relationship("Product", back_populates="brand")
|
||||||
|
shops_with_brand = relationship("BrandInShop", back_populates="brand")
|
||||||
|
|
||||||
class GroceryCategory(Base):
|
class GroceryCategory(Base):
|
||||||
__tablename__ = "grocery_categories"
|
__tablename__ = "grocery_categories"
|
||||||
@@ -37,27 +141,14 @@ class GroceryCategory(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
groceries = relationship("Grocery", back_populates="category")
|
products = relationship("Product", back_populates="category")
|
||||||
|
|
||||||
class Grocery(Base):
|
|
||||||
__tablename__ = "groceries"
|
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
|
||||||
name = Column(String, nullable=False, index=True)
|
|
||||||
category_id = Column(Integer, ForeignKey("grocery_categories.id"), nullable=False)
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
|
||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
|
||||||
|
|
||||||
# Relationships
|
|
||||||
category = relationship("GroceryCategory", back_populates="groceries")
|
|
||||||
products = relationship("Product", back_populates="grocery")
|
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
__tablename__ = "products"
|
__tablename__ = "products"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, nullable=False, index=True)
|
name = Column(String, nullable=False, index=True)
|
||||||
grocery_id = Column(Integer, ForeignKey("groceries.id"), nullable=False)
|
category_id = Column(Integer, ForeignKey("grocery_categories.id"), nullable=False)
|
||||||
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True)
|
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True)
|
||||||
organic = Column(Boolean, default=False)
|
organic = Column(Boolean, default=False)
|
||||||
weight = Column(Float, nullable=True) # in grams or kg
|
weight = Column(Float, nullable=True) # in grams or kg
|
||||||
@@ -65,11 +156,61 @@ class Product(Base):
|
|||||||
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
|
||||||
grocery = relationship("Grocery", back_populates="products")
|
category = relationship("GroceryCategory", back_populates="products")
|
||||||
brand = relationship("Brand", back_populates="products")
|
brand = relationship("Brand", back_populates="products")
|
||||||
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
|
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
|
||||||
|
|
||||||
|
# Self-referential many-to-many relationship for related products
|
||||||
|
# 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"
|
||||||
|
|
||||||
@@ -82,6 +223,7 @@ class Shop(Base):
|
|||||||
|
|
||||||
# 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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
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
|
||||||
|
|
||||||
# Brand schemas
|
# Brand schemas
|
||||||
class BrandBase(BaseModel):
|
class BrandBase(BaseModel):
|
||||||
@@ -20,6 +20,28 @@ class Brand(BrandBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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
|
# Grocery Category schemas
|
||||||
class GroceryCategoryBase(BaseModel):
|
class GroceryCategoryBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -38,57 +60,85 @@ class GroceryCategory(GroceryCategoryBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
# Grocery schemas
|
# Product schemas
|
||||||
class GroceryBase(BaseModel):
|
|
||||||
name: str
|
|
||||||
category_id: int
|
|
||||||
|
|
||||||
class GroceryCreate(GroceryBase):
|
|
||||||
pass
|
|
||||||
|
|
||||||
class GroceryUpdate(BaseModel):
|
|
||||||
name: Optional[str] = None
|
|
||||||
category_id: Optional[int] = None
|
|
||||||
|
|
||||||
class Grocery(GroceryBase):
|
|
||||||
id: int
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: Optional[datetime] = None
|
|
||||||
category: GroceryCategory
|
|
||||||
|
|
||||||
class Config:
|
|
||||||
from_attributes = True
|
|
||||||
|
|
||||||
# Base schemas
|
|
||||||
class ProductBase(BaseModel):
|
class ProductBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
grocery_id: int
|
category_id: int
|
||||||
brand_id: Optional[int] = None
|
brand_id: Optional[int] = None
|
||||||
organic: bool = False
|
organic: bool = False
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
weight_unit: str = "g"
|
weight_unit: str = "g"
|
||||||
|
|
||||||
class ProductCreate(ProductBase):
|
class ProductCreate(ProductBase):
|
||||||
pass
|
valid_from: Optional[date] = None # If not provided, will use current date
|
||||||
|
|
||||||
class ProductUpdate(BaseModel):
|
class ProductUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
grocery_id: Optional[int] = None
|
category_id: Optional[int] = None
|
||||||
brand_id: Optional[int] = None
|
brand_id: Optional[int] = None
|
||||||
organic: Optional[bool] = None
|
organic: Optional[bool] = None
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
weight_unit: Optional[str] = None
|
weight_unit: Optional[str] = None
|
||||||
|
valid_from: Optional[date] = None # If not provided, will use current date
|
||||||
|
|
||||||
class Product(ProductBase):
|
class Product(ProductBase):
|
||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
grocery: Grocery
|
category: GroceryCategory
|
||||||
brand: Optional[Brand] = None
|
brand: Optional[Brand] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
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:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
# Shop schemas
|
# Shop schemas
|
||||||
class ShopBase(BaseModel):
|
class ShopBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@@ -116,17 +166,19 @@ class ProductInEvent(BaseModel):
|
|||||||
product_id: int
|
product_id: int
|
||||||
amount: float = Field(..., gt=0)
|
amount: float = Field(..., gt=0)
|
||||||
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
|
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
|
||||||
|
discount: bool = False # Whether this product was purchased with a discount
|
||||||
|
|
||||||
class ProductWithEventData(BaseModel):
|
class ProductWithEventData(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
grocery: Grocery
|
category: GroceryCategory
|
||||||
brand: Optional[Brand] = None
|
brand: Optional[Brand] = None
|
||||||
organic: bool
|
organic: bool
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
weight_unit: str
|
weight_unit: str
|
||||||
amount: float # Amount purchased in this event
|
amount: float # Amount purchased in this event
|
||||||
price: float # Price at the time of this event
|
price: float # Price at the time of this event
|
||||||
|
discount: bool # Whether this product was purchased with a discount
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@@ -169,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
8
backend/version.py
Normal 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"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<mxfile host="65bd71144e">
|
<mxfile host="65bd71144e">
|
||||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||||
<mxGraphModel dx="1848" dy="501" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
<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>
|
<root>
|
||||||
<mxCell id="0"/>
|
<mxCell id="0"/>
|
||||||
<mxCell id="1" parent="0"/>
|
<mxCell id="1" parent="0"/>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
|
<mxGeometry x="110" y="10" width="320" height="40" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="420" y="470" width="180" height="300" as="geometry"/>
|
<mxGeometry x="420" y="470" width="180" height="300" as="geometry"/>
|
||||||
@@ -58,7 +58,7 @@
|
|||||||
<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">
|
<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"/>
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="129" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="128" vertex="1">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
@@ -71,12 +71,12 @@
|
|||||||
<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">
|
<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"/>
|
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="10" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="11" value="grocery_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
@@ -241,7 +241,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="70" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="70" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="80" y="80" width="180" height="210" as="geometry"/>
|
<mxGeometry x="120" y="90" width="180" height="210" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
@@ -322,10 +322,10 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="95" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="95" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="810" y="200" width="240" height="180" as="geometry"/>
|
<mxGeometry x="760" y="210" width="220" height="210" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
|
||||||
<mxGeometry y="30" width="240" height="30" as="geometry"/>
|
<mxGeometry y="30" width="220" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -333,12 +333,12 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
|
||||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
<mxGeometry x="30" width="190" height="30" as="geometry">
|
||||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
<mxRectangle width="190" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="99" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||||
<mxGeometry y="60" width="240" height="30" as="geometry"/>
|
<mxGeometry y="60" width="220" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -346,12 +346,12 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
|
||||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
<mxGeometry x="30" width="190" height="30" as="geometry">
|
||||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
<mxRectangle width="190" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="102" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||||
<mxGeometry y="90" width="240" height="30" as="geometry"/>
|
<mxGeometry y="90" width="220" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -359,12 +359,12 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
||||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
<mxGeometry x="30" width="190" height="30" as="geometry">
|
||||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
<mxRectangle width="190" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="105" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||||
<mxGeometry y="120" width="240" height="30" as="geometry"/>
|
<mxGeometry y="120" width="220" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -372,12 +372,12 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
|
||||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
<mxGeometry x="30" width="190" height="30" as="geometry">
|
||||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
<mxRectangle width="190" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="108" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||||
<mxGeometry y="150" width="240" height="30" as="geometry"/>
|
<mxGeometry y="150" width="220" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -385,12 +385,25 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1">
|
||||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
<mxGeometry x="30" width="190" height="30" as="geometry">
|
||||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
<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>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="114" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="114" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="90" y="480" width="180" height="150" as="geometry"/>
|
<mxGeometry x="-410" y="400" width="180" height="150" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
@@ -451,140 +464,229 @@
|
|||||||
<Array as="points"/>
|
<Array as="points"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="131" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="148" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="90" y="700" width="180" height="180" as="geometry"/>
|
<mxGeometry x="30" y="580" width="180" height="150" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="132" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="131" vertex="1">
|
<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"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="133" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="134" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="135" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
<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"/>
|
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="136" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="137" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
<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"/>
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="140" value="category_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="138" vertex="1">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="141" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
<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"/>
|
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="141" vertex="1">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="143" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="141" vertex="1">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="144" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
<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 y="150" width="180" height="30" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="145" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="144" vertex="1">
|
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="146" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="144" vertex="1">
|
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
|
||||||
</mxGeometry>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="147" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="132" target="9" edge="1">
|
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
<mxPoint x="280" y="535" as="sourcePoint"/>
|
<mxPoint x="270" y="785" as="sourcePoint"/>
|
||||||
<mxPoint x="430" y="585" as="targetPoint"/>
|
<mxPoint x="90" y="805" as="targetPoint"/>
|
||||||
<Array as="points"/>
|
<Array as="points"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="148" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
<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 x="-210" y="715" width="180" height="150" as="geometry"/>
|
<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>
|
||||||
<mxCell id="149" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="148">
|
<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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands_in_shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="-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"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="150" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="152" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
<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"/>
|
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="154" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
|
<mxCell id="189" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: nowrap;">shop_id: INTEGER</span>" 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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
<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"/>
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="156" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="155">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="155">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="158" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
<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"/>
|
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="158">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="160" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="158">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="149" target="138">
|
<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="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">related_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
|
<mxGeometry x="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">
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
<mxPoint x="270" y="785" as="sourcePoint"/>
|
<mxPoint x="700" y="810" as="sourcePoint"/>
|
||||||
<mxPoint x="80" y="835" as="targetPoint"/>
|
<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"/>
|
<Array as="points"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node:18-alpine as build
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
|||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@@ -9,9 +9,11 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
{
|
{
|
||||||
"name": "product-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
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 KiB |
11
frontend/public/favicon.svg
Normal file
11
frontend/public/favicon.svg
Normal 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 |
@@ -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 product prices and shopping events"
|
content="Track product prices and shopping events"
|
||||||
/>
|
/>
|
||||||
<title>Product 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>
|
||||||
|
|||||||
22
frontend/public/manifest.json
Normal file
22
frontend/public/manifest.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -1,123 +1,174 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
import ShopList from './components/ShopList';
|
import ShopList from './components/ShopList';
|
||||||
import ProductList from './components/ProductList';
|
import ProductList from './components/ProductList';
|
||||||
import ShoppingEventList from './components/ShoppingEventList';
|
import ShoppingEventList from './components/ShoppingEventList';
|
||||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
|
||||||
import BrandList from './components/BrandList';
|
import BrandList from './components/BrandList';
|
||||||
import GroceryList from './components/GroceryList';
|
|
||||||
import GroceryCategoryList from './components/GroceryCategoryList';
|
import GroceryCategoryList from './components/GroceryCategoryList';
|
||||||
|
import ImportExportModal from './components/ImportExportModal';
|
||||||
|
import AboutModal from './components/AboutModal';
|
||||||
|
|
||||||
function Navigation() {
|
function Navigation({ onImportExportClick, onAboutClick }: { onImportExportClick: () => void; onAboutClick: () => void }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||||
|
|
||||||
const isActive = (path: string) => {
|
const isActive = (path: string) => {
|
||||||
return location.pathname === path;
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="bg-blue-600 shadow-lg">
|
<nav className="bg-blue-600 shadow-lg">
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="flex justify-between h-16">
|
<div className="flex justify-between h-16">
|
||||||
<div className="flex space-x-8">
|
{/* Desktop Navigation */}
|
||||||
<Link
|
<div className="hidden md:flex space-x-8">
|
||||||
to="/"
|
{navLinks.map(({ path, label }) => (
|
||||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
<NavLink key={path} path={path} label={label} />
|
||||||
isActive('/')
|
))}
|
||||||
? 'text-white border-b-2 border-white'
|
</div>
|
||||||
: 'text-blue-100 hover:text-white'
|
|
||||||
}`}
|
{/* Mobile menu button */}
|
||||||
|
<div className="md:hidden flex items-center">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
|
||||||
|
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"
|
||||||
|
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="/shopping-events"
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
|
||||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
</svg>
|
||||||
isActive('/shopping-events')
|
) : (
|
||||||
? 'text-white border-b-2 border-white'
|
<svg className="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
: 'text-blue-100 hover:text-white'
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
}`}
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Desktop Action Buttons */}
|
||||||
|
<div className="hidden sm:flex items-center space-x-2">
|
||||||
|
<button
|
||||||
|
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
|
<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" />
|
||||||
<Link
|
</svg>
|
||||||
to="/shops"
|
<span className="hidden sm:inline">Import / Export</span>
|
||||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
</button>
|
||||||
isActive('/shops')
|
<button
|
||||||
? 'text-white border-b-2 border-white'
|
onClick={onAboutClick}
|
||||||
: 'text-blue-100 hover:text-white'
|
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"
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
Shops
|
<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="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
<Link
|
</svg>
|
||||||
to="/products"
|
<span className="hidden sm:inline">About</span>
|
||||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
</button>
|
||||||
isActive('/products')
|
|
||||||
? 'text-white border-b-2 border-white'
|
|
||||||
: 'text-blue-100 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Products
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/brands"
|
|
||||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
|
||||||
isActive('/brands')
|
|
||||||
? 'text-white border-b-2 border-white'
|
|
||||||
: 'text-blue-100 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Brands
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/groceries"
|
|
||||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
|
||||||
isActive('/groceries')
|
|
||||||
? 'text-white border-b-2 border-white'
|
|
||||||
: 'text-blue-100 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Groceries
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/categories"
|
|
||||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
|
||||||
isActive('/categories')
|
|
||||||
? 'text-white border-b-2 border-white'
|
|
||||||
: 'text-blue-100 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Categories
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
const [showImportExportModal, setShowImportExportModal] = useState(false);
|
||||||
|
const [showAboutModal, setShowAboutModal] = useState(false);
|
||||||
|
|
||||||
|
const handleDataChanged = () => {
|
||||||
|
// This will be called when data is imported, but since we're at the app level,
|
||||||
|
// individual components will need to handle their own refresh
|
||||||
|
// The modal will close automatically after successful import
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navigation />
|
<Navigation
|
||||||
<main className="py-10">
|
onImportExportClick={() => setShowImportExportModal(true)}
|
||||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
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>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||||
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
|
|
||||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
|
||||||
<Route path="/shops" element={<ShopList />} />
|
<Route path="/shops" element={<ShopList />} />
|
||||||
<Route path="/products" element={<ProductList />} />
|
<Route path="/products" element={<ProductList />} />
|
||||||
<Route path="/brands" element={<BrandList />} />
|
<Route path="/brands" element={<BrandList />} />
|
||||||
<Route path="/groceries" element={<GroceryList />} />
|
|
||||||
<Route path="/categories" element={<GroceryCategoryList />} />
|
<Route path="/categories" element={<GroceryCategoryList />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<ImportExportModal
|
||||||
|
isOpen={showImportExportModal}
|
||||||
|
onClose={() => setShowImportExportModal(false)}
|
||||||
|
onDataChanged={handleDataChanged}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AboutModal
|
||||||
|
isOpen={showAboutModal}
|
||||||
|
onClose={() => setShowAboutModal(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Router>
|
</Router>
|
||||||
);
|
);
|
||||||
|
|||||||
154
frontend/src/components/AboutModal.tsx
Normal file
154
frontend/src/components/AboutModal.tsx
Normal 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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { brandApi } from '../services/api';
|
import { brandApi } from '../services/api';
|
||||||
import { Brand } from '../types';
|
import { Brand } from '../types';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
interface AddBrandModalProps {
|
interface AddBrandModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -22,6 +23,9 @@ const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandA
|
|||||||
|
|
||||||
const isEditMode = !!editBrand;
|
const isEditMode = !!editBrand;
|
||||||
|
|
||||||
|
// Use body scroll lock when modal is open
|
||||||
|
useBodyScrollLock(isOpen);
|
||||||
|
|
||||||
// Initialize form data when editing
|
// Initialize form data when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editBrand) {
|
if (editBrand) {
|
||||||
@@ -36,7 +40,7 @@ const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandA
|
|||||||
setError('');
|
setError('');
|
||||||
}, [editBrand, isOpen]);
|
}, [editBrand, isOpen]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
setError('Please enter a brand name');
|
setError('Please enter a brand name');
|
||||||
@@ -70,8 +74,32 @@ const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandA
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -83,8 +111,19 @@ const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandA
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<div
|
||||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
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="mt-3">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { GroceryCategory, GroceryCategoryCreate } from '../types';
|
import { GroceryCategory, GroceryCategoryCreate } from '../types';
|
||||||
import { groceryCategoryApi } from '../services/api';
|
import { groceryCategoryApi } from '../services/api';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
interface AddGroceryCategoryModalProps {
|
interface AddGroceryCategoryModalProps {
|
||||||
category?: GroceryCategory | null;
|
category?: GroceryCategory | null;
|
||||||
@@ -8,6 +9,9 @@ interface AddGroceryCategoryModalProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ category, onClose }) => {
|
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>({
|
const [formData, setFormData] = useState<GroceryCategoryCreate>({
|
||||||
name: ''
|
name: ''
|
||||||
});
|
});
|
||||||
@@ -24,7 +28,7 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
|||||||
}
|
}
|
||||||
}, [category]);
|
}, [category]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setMessage('');
|
setMessage('');
|
||||||
@@ -44,24 +48,54 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving category:', error);
|
console.error('Error saving category:', error);
|
||||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
|
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<div
|
||||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
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="mt-3">
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||||
{isEditMode ? 'Edit Category' : 'Add New Category'}
|
{isEditMode ? 'Edit Category' : 'Add New Category'}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
<div className={`mb-4 px-4 py-3 rounded ${
|
||||||
message.includes('Error')
|
message.includes('Error')
|
||||||
? 'bg-red-50 text-red-700'
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
: 'bg-green-50 text-green-700'
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Grocery, GroceryCreate, GroceryCategory } from '../types';
|
|
||||||
import { groceryApi, groceryCategoryApi } from '../services/api';
|
|
||||||
|
|
||||||
interface AddGroceryModalProps {
|
|
||||||
grocery?: Grocery | null;
|
|
||||||
onClose: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) => {
|
|
||||||
const [formData, setFormData] = useState<GroceryCreate>({
|
|
||||||
name: '',
|
|
||||||
category_id: 0
|
|
||||||
});
|
|
||||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
|
|
||||||
const isEditMode = Boolean(grocery);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCategories();
|
|
||||||
if (grocery) {
|
|
||||||
setFormData({
|
|
||||||
name: grocery.name,
|
|
||||||
category_id: grocery.category_id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [grocery]);
|
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
|
||||||
try {
|
|
||||||
const response = await groceryCategoryApi.getAll();
|
|
||||||
setCategories(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching categories:', error);
|
|
||||||
setMessage('Error loading categories. Please try again.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setMessage('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (isEditMode && grocery) {
|
|
||||||
await groceryApi.update(grocery.id, formData);
|
|
||||||
setMessage('Grocery updated successfully!');
|
|
||||||
} else {
|
|
||||||
await groceryApi.create(formData);
|
|
||||||
setMessage('Grocery created successfully!');
|
|
||||||
}
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
onClose();
|
|
||||||
}, 1500);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error saving grocery:', error);
|
|
||||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
|
||||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
|
||||||
<div className="mt-3">
|
|
||||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
|
||||||
{isEditMode ? 'Edit Grocery' : 'Add New Grocery'}
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
|
||||||
message.includes('Error')
|
|
||||||
? 'bg-red-50 text-red-700'
|
|
||||||
: 'bg-green-50 text-green-700'
|
|
||||||
}`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
|
||||||
Grocery Name
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Enter grocery name"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
|
||||||
Category
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.category_id}
|
|
||||||
onChange={(e) => setFormData({...formData, category_id: parseInt(e.target.value)})}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value={0}>Select a category</option>
|
|
||||||
{categories.map(category => (
|
|
||||||
<option key={category.id} value={category.id}>
|
|
||||||
{category.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || formData.category_id === 0}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
|
||||||
: (isEditMode ? 'Update Grocery' : 'Create Grocery')
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AddGroceryModal;
|
|
||||||
@@ -1,44 +1,54 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { productApi, brandApi, groceryApi } from '../services/api';
|
import { productApi, brandApi, groceryCategoryApi, utilityApi } from '../services/api';
|
||||||
import { Product, Brand, Grocery } from '../types';
|
import { Product, Brand, GroceryCategory } from '../types';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
interface AddProductModalProps {
|
interface AddProductModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onProductAdded: () => void;
|
onProductAdded: () => void;
|
||||||
editProduct?: Product | null;
|
editProduct?: Product | null;
|
||||||
|
duplicateProduct?: Product | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
name: string;
|
name: string;
|
||||||
grocery_id?: number;
|
category_id?: number;
|
||||||
brand_id?: number;
|
brand_id?: number;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
|
valid_from: string; // ISO date string (YYYY-MM-DD)
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
|
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct, duplicateProduct }) => {
|
||||||
const [formData, setFormData] = useState<ProductFormData>({
|
const [formData, setFormData] = useState<ProductFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
grocery_id: undefined,
|
category_id: undefined,
|
||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
weight_unit: 'piece'
|
weight_unit: 'piece',
|
||||||
|
valid_from: ''
|
||||||
});
|
});
|
||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
const [currentDate, setCurrentDate] = useState('');
|
||||||
|
const [minValidFromDate, setMinValidFromDate] = useState('');
|
||||||
|
|
||||||
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
||||||
|
|
||||||
// Fetch brands and groceries when modal opens
|
// Use body scroll lock when modal is open
|
||||||
|
useBodyScrollLock(isOpen);
|
||||||
|
|
||||||
|
// Fetch brands and categories when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchBrands();
|
fetchBrands();
|
||||||
fetchGroceries();
|
fetchCategories();
|
||||||
|
fetchCurrentDate();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -51,60 +61,148 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchGroceries = async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await groceryApi.getAll();
|
const response = await groceryCategoryApi.getAll();
|
||||||
setGroceries(response.data);
|
setCategories(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching groceries:', err);
|
console.error('Error fetching categories:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Populate form when editing
|
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(() => {
|
useEffect(() => {
|
||||||
if (editProduct) {
|
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({
|
setFormData({
|
||||||
name: editProduct.name,
|
name: editProduct.name,
|
||||||
grocery_id: editProduct.grocery_id,
|
category_id: editProduct.category_id,
|
||||||
brand_id: editProduct.brand_id,
|
brand_id: editProduct.brand_id,
|
||||||
organic: editProduct.organic,
|
organic: editProduct.organic,
|
||||||
weight: editProduct.weight,
|
weight: editProduct.weight,
|
||||||
weight_unit: editProduct.weight_unit
|
weight_unit: editProduct.weight_unit,
|
||||||
|
valid_from: currentDate // Default to today for edits
|
||||||
});
|
});
|
||||||
} else {
|
} catch (err) {
|
||||||
// Reset form for adding new product
|
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({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
grocery_id: undefined,
|
category_id: undefined,
|
||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
weight_unit: 'piece'
|
weight_unit: 'piece',
|
||||||
|
valid_from: currentDate
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setError('');
|
}, [editProduct, duplicateProduct, isOpen, currentDate]);
|
||||||
}, [editProduct, isOpen]);
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim() || !formData.grocery_id) {
|
if (!formData.name.trim() || !formData.category_id || !formData.valid_from) {
|
||||||
setError('Please fill in all required fields with valid values');
|
setError('Please fill in all required fields with valid values');
|
||||||
return;
|
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 {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const productData = {
|
const productData: any = {
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
grocery_id: formData.grocery_id!,
|
category_id: formData.category_id!,
|
||||||
brand_id: formData.brand_id || undefined,
|
brand_id: formData.brand_id || undefined,
|
||||||
organic: formData.organic,
|
organic: formData.organic,
|
||||||
weight: formData.weight || undefined,
|
weight: formData.weight || undefined,
|
||||||
weight_unit: formData.weight_unit
|
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) {
|
if (editProduct) {
|
||||||
// Update existing product
|
// Update existing product
|
||||||
await productApi.update(editProduct.id, productData);
|
await productApi.update(editProduct.id, productData);
|
||||||
@@ -116,11 +214,12 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
// Reset form
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
grocery_id: undefined,
|
category_id: undefined,
|
||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
weight_unit: 'piece'
|
weight_unit: 'piece',
|
||||||
|
valid_from: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
onProductAdded();
|
onProductAdded();
|
||||||
@@ -131,15 +230,35 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||||
const { name, value, type } = e.target;
|
const { name, value, type } = e.target;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
|
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
|
||||||
: type === 'number' ? (value === '' ? undefined : Number(value))
|
: type === 'number' ? (value === '' ? undefined : Number(value))
|
||||||
: name === 'brand_id' || name === 'grocery_id' ? (value === '' ? undefined : Number(value))
|
: name === 'brand_id' || name === 'category_id' ? (value === '' ? undefined : Number(value))
|
||||||
: value
|
: value
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -147,12 +266,23 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<div
|
||||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
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="mt-3">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
{editProduct ? 'Edit Product' : 'Add New Product'}
|
{editProduct ? 'Edit Product' : duplicateProduct ? 'Duplicate Product' : 'Add New Product'}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
@@ -183,26 +313,60 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
placeholder="e.g., Whole Foods Organic Milk"
|
placeholder="Product name"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="grocery_id" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="valid_from" className="block text-sm font-medium text-gray-700">
|
||||||
Grocery Type *
|
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>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="grocery_id"
|
id="category_id"
|
||||||
name="grocery_id"
|
name="category_id"
|
||||||
value={formData.grocery_id || ''}
|
value={formData.category_id || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Select a grocery type</option>
|
<option value="">Select a category</option>
|
||||||
{groceries.map(grocery => (
|
{categories.map(category => (
|
||||||
<option key={grocery.id} value={grocery.id}>
|
<option key={category.id} value={category.id}>
|
||||||
{grocery.name} ({grocery.category.name})
|
{category.name}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
@@ -289,8 +453,8 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
? (editProduct ? 'Updating...' : 'Adding...')
|
? (editProduct ? 'Updating...' : duplicateProduct ? 'Duplicating...' : 'Adding...')
|
||||||
: (editProduct ? 'Update Product' : 'Add Product')
|
: (editProduct ? 'Update Product' : duplicateProduct ? 'Duplicate Product' : 'Add Product')
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { shopApi } from '../services/api';
|
import { shopApi, brandApi, brandInShopApi } from '../services/api';
|
||||||
import { Shop } from '../types';
|
import { Shop, Brand, BrandInShop } from '../types';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
interface AddShopModalProps {
|
interface AddShopModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -13,38 +14,79 @@ interface ShopFormData {
|
|||||||
name: string;
|
name: string;
|
||||||
city: string;
|
city: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
selectedBrands: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdded, editShop }) => {
|
const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdded, editShop }) => {
|
||||||
const [formData, setFormData] = useState<ShopFormData>({
|
const [formData, setFormData] = useState<ShopFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
city: '',
|
city: '',
|
||||||
address: ''
|
address: '',
|
||||||
|
selectedBrands: []
|
||||||
});
|
});
|
||||||
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const isEditMode = !!editShop;
|
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
|
// Initialize form data when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editShop) {
|
if (editShop) {
|
||||||
setFormData({
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
name: editShop.name,
|
name: editShop.name,
|
||||||
city: editShop.city,
|
city: editShop.city,
|
||||||
address: editShop.address || ''
|
address: editShop.address || ''
|
||||||
});
|
}));
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
city: '',
|
city: '',
|
||||||
address: ''
|
address: '',
|
||||||
|
selectedBrands: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setError('');
|
setError('');
|
||||||
}, [editShop, isOpen]);
|
}, [editShop, isOpen]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = useCallback(async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim() || !formData.city.trim()) {
|
if (!formData.name.trim() || !formData.city.trim()) {
|
||||||
setError('Please fill in all required fields');
|
setError('Please fill in all required fields');
|
||||||
@@ -62,17 +104,48 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
|||||||
address: trimmedAddress && trimmedAddress.length > 0 ? trimmedAddress : null
|
address: trimmedAddress && trimmedAddress.length > 0 ? trimmedAddress : null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let shopId: number;
|
||||||
|
|
||||||
if (isEditMode && editShop) {
|
if (isEditMode && editShop) {
|
||||||
await shopApi.update(editShop.id, shopData);
|
await shopApi.update(editShop.id, shopData);
|
||||||
|
shopId = editShop.id;
|
||||||
} else {
|
} else {
|
||||||
await shopApi.create(shopData);
|
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
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
city: '',
|
city: '',
|
||||||
address: ''
|
address: '',
|
||||||
|
selectedBrands: []
|
||||||
});
|
});
|
||||||
|
|
||||||
onShopAdded();
|
onShopAdded();
|
||||||
@@ -83,8 +156,29 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
const { name, value } = e.target;
|
const { name, value } = e.target;
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -93,11 +187,31 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<div
|
||||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
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="mt-3">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
@@ -167,6 +281,34 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
658
frontend/src/components/AddShoppingEventModal.tsx
Normal file
658
frontend/src/components/AddShoppingEventModal.tsx
Normal 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;
|
||||||
@@ -14,6 +14,8 @@ const BrandList: React.FC = () => {
|
|||||||
const [editingBrand, setEditingBrand] = useState<Brand | null>(null);
|
const [editingBrand, setEditingBrand] = useState<Brand | null>(null);
|
||||||
const [deletingBrand, setDeletingBrand] = useState<Brand | null>(null);
|
const [deletingBrand, setDeletingBrand] = useState<Brand | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [sortField, setSortField] = useState<keyof Brand>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBrands();
|
fetchBrands();
|
||||||
@@ -82,6 +84,58 @@ const BrandList: React.FC = () => {
|
|||||||
setDeletingBrand(null);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -92,11 +146,11 @@ const BrandList: 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">Brands</h1>
|
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Brands</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
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
|
Add New Brand
|
||||||
</button>
|
</button>
|
||||||
@@ -118,47 +172,114 @@ const BrandList: React.FC = () => {
|
|||||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
|
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
<>
|
||||||
{brands.map((brand) => (
|
{/* Desktop Table */}
|
||||||
<div key={brand.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">{brand.name}</h3>
|
<thead className="bg-gray-50">
|
||||||
<div className="flex space-x-2">
|
<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
|
<button
|
||||||
onClick={() => handleEditBrand(brand)}
|
onClick={() => handleEditBrand(brand)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteBrand(brand)}
|
onClick={() => handleDeleteBrand(brand)}
|
||||||
className="text-red-600 hover:text-red-900 text-sm"
|
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
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
Added {new Date(brand.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{brand.updated_at && (
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Updated {new Date(brand.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
|
||||||
|
|
||||||
interface ConfirmDeleteModalProps {
|
interface ConfirmDeleteModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -17,11 +18,43 @@ const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({
|
|||||||
message,
|
message,
|
||||||
isLoading = false
|
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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<div
|
||||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
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="mt-3">
|
||||||
<div className="flex items-center mb-4">
|
<div className="flex items-center mb-4">
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
|
|||||||
@@ -6,7 +6,21 @@ import { shoppingEventApi } from '../services/api';
|
|||||||
const Dashboard: React.FC = () => {
|
const Dashboard: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [recentEvents, setRecentEvents] = useState<ShoppingEvent[]>([]);
|
const [recentEvents, setRecentEvents] = useState<ShoppingEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchRecentEvents();
|
fetchRecentEvents();
|
||||||
@@ -18,7 +32,19 @@ const Dashboard: React.FC = () => {
|
|||||||
const response = await shoppingEventApi.getAll();
|
const response = await shoppingEventApi.getAll();
|
||||||
// Get the 3 most recent events
|
// Get the 3 most recent events
|
||||||
const recent = response.data
|
const recent = response.data
|
||||||
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
.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);
|
.slice(0, 3);
|
||||||
setRecentEvents(recent);
|
setRecentEvents(recent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -31,8 +57,8 @@ const Dashboard: React.FC = () => {
|
|||||||
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 product 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">
|
||||||
@@ -96,21 +122,21 @@ 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
|
<button
|
||||||
onClick={() => navigate('/shopping-events/new')}
|
onClick={() => navigate('/shopping-events?add=true')}
|
||||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
<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 New Event</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>
|
||||||
@@ -118,14 +144,14 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/products?add=true')}
|
onClick={() => navigate('/products?add=true')}
|
||||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div className="p-2 bg-green-100 rounded-md mr-3">
|
<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 Product</p>
|
<p className="font-medium text-gray-900">Add Product</p>
|
||||||
<p className="text-sm text-gray-600">Add a new product item</p>
|
<p className="text-sm text-gray-600">Add a new product item</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,14 +159,14 @@ const Dashboard: React.FC = () => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/shops?add=true')}
|
onClick={() => navigate('/shops?add=true')}
|
||||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors text-left"
|
||||||
>
|
>
|
||||||
<div className="p-2 bg-purple-100 rounded-md mr-3">
|
<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>
|
||||||
@@ -151,10 +177,10 @@ 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">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
@@ -172,14 +198,14 @@ const Dashboard: React.FC = () => {
|
|||||||
{recentEvents.map((event) => (
|
{recentEvents.map((event) => (
|
||||||
<div key={event.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
|
<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 justify-between items-start">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:space-x-2">
|
||||||
<h4 className="font-medium text-gray-900">{event.shop.name}</h4>
|
<h4 className="font-medium text-gray-900 truncate">{event.shop.name}</h4>
|
||||||
<span className="text-sm text-gray-500">•</span>
|
<span className="hidden sm:inline text-sm text-gray-500">•</span>
|
||||||
<span className="text-sm text-gray-500">{event.shop.city}</span>
|
<span className="text-sm text-gray-500">{event.shop.city}</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-600 mt-1">
|
<p className="text-sm text-gray-600 mt-1">
|
||||||
{new Date(event.date).toLocaleDateString()}
|
{formatDate(event.date)}
|
||||||
</p>
|
</p>
|
||||||
{event.products.length > 0 && (
|
{event.products.length > 0 && (
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
@@ -187,7 +213,7 @@ const Dashboard: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right flex-shrink-0 ml-4">
|
||||||
{event.total_amount && (
|
{event.total_amount && (
|
||||||
<p className="font-semibold text-green-600">
|
<p className="font-semibold text-green-600">
|
||||||
${event.total_amount.toFixed(2)}
|
${event.total_amount.toFixed(2)}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { GroceryCategory } from '../types';
|
import { GroceryCategory } from '../types';
|
||||||
import { groceryCategoryApi } from '../services/api';
|
import { groceryCategoryApi } from '../services/api';
|
||||||
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
||||||
|
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
|
||||||
const GroceryCategoryList: React.FC = () => {
|
const GroceryCategoryList: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||||
@@ -9,6 +10,10 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<GroceryCategory | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
@@ -27,13 +32,20 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (category: GroceryCategory) => {
|
||||||
if (window.confirm('Are you sure you want to delete this category?')) {
|
setDeletingCategory(category);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!deletingCategory) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await groceryCategoryApi.delete(id);
|
setDeleteLoading(true);
|
||||||
|
await groceryCategoryApi.delete(deletingCategory.id);
|
||||||
setMessage('Category deleted successfully!');
|
setMessage('Category deleted successfully!');
|
||||||
|
setDeletingCategory(null);
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
setTimeout(() => setMessage(''), 3000);
|
setTimeout(() => setMessage(''), 1500);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Error deleting category:', error);
|
console.error('Error deleting category:', error);
|
||||||
if (error.response?.status === 400) {
|
if (error.response?.status === 400) {
|
||||||
@@ -41,11 +53,16 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
} else {
|
} else {
|
||||||
setMessage('Error deleting category. Please try again.');
|
setMessage('Error deleting category. Please try again.');
|
||||||
}
|
}
|
||||||
setTimeout(() => setMessage(''), 5000);
|
setTimeout(() => setMessage(''), 3000);
|
||||||
}
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setDeletingCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (category: GroceryCategory) => {
|
const handleEdit = (category: GroceryCategory) => {
|
||||||
setEditingCategory(category);
|
setEditingCategory(category);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@@ -57,6 +74,58 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
fetchCategories();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -66,69 +135,86 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="space-y-6">
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Grocery Categories</h1>
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Grocery Categories
|
|
||||||
</h3>
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
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 Category
|
Add New Category
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
<div className={`px-4 py-3 rounded ${
|
||||||
message.includes('Error') || message.includes('Cannot')
|
message.includes('Error') || message.includes('Cannot')
|
||||||
? 'bg-red-50 text-red-700'
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
: 'bg-green-50 text-green-700'
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
{categories.length === 0 ? (
|
{categories.length === 0 ? (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-12">
|
||||||
<p className="text-gray-500">No categories found. Add your first category!</p>
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
<>
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
{/* Desktop Table */}
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
|
className="px-6 py-3 text-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
|
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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
Actions
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{categories.map((category) => (
|
{sortedCategories.map((category) => (
|
||||||
<tr key={category.id} className="hover:bg-gray-50">
|
<tr key={category.id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
{category.name}
|
{category.name}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
{new Date(category.created_at).toLocaleDateString()}
|
{new Date(category.created_at).toLocaleDateString()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(category)}
|
onClick={() => handleEdit(category)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(category.id)}
|
onClick={() => handleDelete(category)}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -139,16 +225,54 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
</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 && (
|
{isModalOpen && (
|
||||||
<AddGroceryCategoryModal
|
<AddGroceryCategoryModal
|
||||||
category={editingCategory}
|
category={editingCategory}
|
||||||
onClose={handleModalClose}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
import { Grocery } from '../types';
|
|
||||||
import { groceryApi } from '../services/api';
|
|
||||||
import AddGroceryModal from './AddGroceryModal';
|
|
||||||
|
|
||||||
const GroceryList: React.FC = () => {
|
|
||||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchGroceries();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchGroceries = async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await groceryApi.getAll();
|
|
||||||
setGroceries(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching groceries:', error);
|
|
||||||
setMessage('Error loading groceries. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (window.confirm('Are you sure you want to delete this grocery?')) {
|
|
||||||
try {
|
|
||||||
await groceryApi.delete(id);
|
|
||||||
setMessage('Grocery deleted successfully!');
|
|
||||||
fetchGroceries();
|
|
||||||
setTimeout(() => setMessage(''), 3000);
|
|
||||||
} catch (error: any) {
|
|
||||||
console.error('Error deleting grocery:', error);
|
|
||||||
if (error.response?.status === 400) {
|
|
||||||
setMessage('Cannot delete grocery: products are still associated with this grocery.');
|
|
||||||
} else {
|
|
||||||
setMessage('Error deleting grocery. Please try again.');
|
|
||||||
}
|
|
||||||
setTimeout(() => setMessage(''), 5000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (grocery: Grocery) => {
|
|
||||||
setEditingGrocery(grocery);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModalClose = () => {
|
|
||||||
setIsModalOpen(false);
|
|
||||||
setEditingGrocery(null);
|
|
||||||
fetchGroceries();
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="bg-white shadow rounded-lg">
|
|
||||||
<div className="px-4 py-5 sm:p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
Groceries
|
|
||||||
</h3>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsModalOpen(true)}
|
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
|
||||||
>
|
|
||||||
Add Grocery
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
|
||||||
message.includes('Error') || message.includes('Cannot')
|
|
||||||
? 'bg-red-50 text-red-700'
|
|
||||||
: 'bg-green-50 text-green-700'
|
|
||||||
}`}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groceries.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Category
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{groceries.map((grocery) => (
|
|
||||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{grocery.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{grocery.category.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{new Date(grocery.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(grocery)}
|
|
||||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(grocery.id)}
|
|
||||||
className="text-red-600 hover:text-red-900"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isModalOpen && (
|
|
||||||
<AddGroceryModal
|
|
||||||
grocery={editingGrocery}
|
|
||||||
onClose={handleModalClose}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GroceryList;
|
|
||||||
561
frontend/src/components/ImportExportModal.tsx
Normal file
561
frontend/src/components/ImportExportModal.tsx
Normal 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;
|
||||||
@@ -14,6 +14,10 @@ const ProductList: React.FC = () => {
|
|||||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
|
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
@@ -24,12 +28,12 @@ const ProductList: React.FC = () => {
|
|||||||
// Remove the parameter from URL
|
// Remove the parameter from URL
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
}
|
}
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams, showDeleted]);
|
||||||
|
|
||||||
const fetchProducts = async () => {
|
const fetchProducts = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await productApi.getAll();
|
const response = await productApi.getAll(showDeleted);
|
||||||
setProducts(response.data);
|
setProducts(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to fetch products');
|
setError('Failed to fetch products');
|
||||||
@@ -44,6 +48,11 @@ const ProductList: React.FC = () => {
|
|||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = (product: Product) => {
|
||||||
|
setDuplicatingProduct(product);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = (product: Product) => {
|
const handleDelete = (product: Product) => {
|
||||||
setDeletingProduct(product);
|
setDeletingProduct(product);
|
||||||
};
|
};
|
||||||
@@ -71,12 +80,95 @@ const ProductList: React.FC = () => {
|
|||||||
const handleCloseModal = () => {
|
const handleCloseModal = () => {
|
||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
|
setDuplicatingProduct(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCloseDeleteModal = () => {
|
const handleCloseDeleteModal = () => {
|
||||||
setDeletingProduct(null);
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -87,18 +179,30 @@ const ProductList: 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">Products</h1>
|
<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
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditingProduct(null);
|
setEditingProduct(null);
|
||||||
|
setDuplicatingProduct(null);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
}}
|
}}
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
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
|
Add New Product
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
@@ -116,23 +220,47 @@ const ProductList: React.FC = () => {
|
|||||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first product item.</p>
|
<p className="mt-1 text-sm text-gray-500">Get started by adding your first product item.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Desktop Table */}
|
||||||
|
<div className="hidden md:block">
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
|
className="px-6 py-3 text-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
|
Name
|
||||||
|
{getSortIcon('name')}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
Grocery
|
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>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
|
className="px-6 py-3 text-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
|
Brand
|
||||||
|
{getSortIcon('brand')}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
|
className="px-6 py-3 text-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
|
Weight
|
||||||
</th>
|
{getSortIcon('weight')}
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
</div>
|
||||||
Organic
|
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
@@ -140,50 +268,110 @@ const ProductList: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{products.map((product) => (
|
{sortedProducts.map((product) => (
|
||||||
<tr key={product.id} className="hover:bg-gray-50">
|
<tr key={product.id} className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className={`text-sm font-medium ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
|
||||||
{product.name} {product.organic ? '🌱' : ''}
|
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||||
<div className="text-sm text-gray-900">{product.grocery.name}</div>
|
{product.category.name}
|
||||||
<div className="text-xs text-gray-500">{product.grocery.category.name}</div>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||||
{product.brand ? product.brand.name : '-'}
|
{product.brand ? product.brand.name : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
|
||||||
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
|
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
|
||||||
product.organic
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{product.organic ? 'Organic' : 'Conventional'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
{!product.deleted ? (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(product)}
|
onClick={() => handleEdit(product)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDuplicate(product)}
|
||||||
|
className="text-green-600 hover:text-green-900 mr-3"
|
||||||
|
>
|
||||||
|
Duplicate
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(product)}
|
onClick={() => handleDelete(product)}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-gray-400 text-sm">Deleted</span>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -192,6 +380,7 @@ const ProductList: React.FC = () => {
|
|||||||
onClose={handleCloseModal}
|
onClose={handleCloseModal}
|
||||||
onProductAdded={handleProductAdded}
|
onProductAdded={handleProductAdded}
|
||||||
editProduct={editingProduct}
|
editProduct={editingProduct}
|
||||||
|
duplicateProduct={duplicatingProduct}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Shop } from '../types';
|
import { Shop, BrandInShop } from '../types';
|
||||||
import { shopApi } from '../services/api';
|
import { shopApi, brandInShopApi } from '../services/api';
|
||||||
import AddShopModal from './AddShopModal';
|
import AddShopModal from './AddShopModal';
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
|
||||||
@@ -14,6 +14,42 @@ const ShopList: React.FC = () => {
|
|||||||
const [editingShop, setEditingShop] = useState<Shop | null>(null);
|
const [editingShop, setEditingShop] = useState<Shop | null>(null);
|
||||||
const [deletingShop, setDeletingShop] = useState<Shop | null>(null);
|
const [deletingShop, setDeletingShop] = useState<Shop | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
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[]>>({});
|
||||||
|
|
||||||
|
const loadShopBrands = useCallback(async (shopId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await brandInShopApi.getByShop(shopId);
|
||||||
|
setShopBrands(prev => ({
|
||||||
|
...prev,
|
||||||
|
[shopId]: response.data
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading shop brands:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchShops = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await shopApi.getAll();
|
||||||
|
setShops(response.data);
|
||||||
|
|
||||||
|
// Load brands for all shops
|
||||||
|
for (const shop of response.data) {
|
||||||
|
loadShopBrands(shop.id);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to fetch shops');
|
||||||
|
console.error('Error fetching shops:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [loadShopBrands]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchShops();
|
fetchShops();
|
||||||
@@ -24,21 +60,27 @@ const ShopList: React.FC = () => {
|
|||||||
// Remove the parameter from URL
|
// Remove the parameter from URL
|
||||||
setSearchParams({});
|
setSearchParams({});
|
||||||
}
|
}
|
||||||
}, [searchParams, setSearchParams]);
|
}, [searchParams, setSearchParams, fetchShops]);
|
||||||
|
|
||||||
const fetchShops = async () => {
|
// Handle clicking outside popup to close it
|
||||||
try {
|
useEffect(() => {
|
||||||
setLoading(true);
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
const response = await shopApi.getAll();
|
const target = event.target as HTMLElement;
|
||||||
setShops(response.data);
|
if (showBrandsPopup && !target.closest('.brands-popup') && !target.closest('.brands-cell')) {
|
||||||
} catch (err) {
|
setShowBrandsPopup(false);
|
||||||
setError('Failed to fetch shops');
|
setHoveredShop(null);
|
||||||
console.error('Error fetching shops:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (showBrandsPopup) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showBrandsPopup]);
|
||||||
|
|
||||||
const handleShopAdded = () => {
|
const handleShopAdded = () => {
|
||||||
fetchShops(); // Refresh the shops list
|
fetchShops(); // Refresh the shops list
|
||||||
};
|
};
|
||||||
@@ -77,6 +119,116 @@ const ShopList: React.FC = () => {
|
|||||||
setDeletingShop(null);
|
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) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -87,11 +239,11 @@ 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
|
<button
|
||||||
onClick={() => setIsModalOpen(true)}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
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>
|
||||||
@@ -113,64 +265,176 @@ 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>
|
||||||
|
<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
|
<button
|
||||||
onClick={() => handleEditShop(shop)}
|
onClick={() => handleEditShop(shop)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteShop(shop)}
|
onClick={() => handleDeleteShop(shop)}
|
||||||
className="text-red-600 hover:text-red-900 text-sm"
|
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
|
||||||
|
</button>
|
||||||
|
<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 className="space-y-2">
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
{shop.city}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shop.address && (
|
|
||||||
<div className="flex items-start text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<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" />
|
|
||||||
</svg>
|
|
||||||
{shop.address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
Added {new Date(shop.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shop.updated_at && (
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Updated {new Date(shop.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -189,6 +453,41 @@ const ShopList: React.FC = () => {
|
|||||||
message={`Are you sure you want to delete "${deletingShop?.name}"? This action cannot be undone.`}
|
message={`Are you sure you want to delete "${deletingShop?.name}"? This action cannot be undone.`}
|
||||||
isLoading={deleteLoading}
|
isLoading={deleteLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Brands Popup */}
|
||||||
|
{showBrandsPopup && hoveredShop && (shopBrands[hoveredShop.id]?.length || 0) > 0 && (
|
||||||
|
<div
|
||||||
|
className="brands-popup fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-sm"
|
||||||
|
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',
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,454 +0,0 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
|
||||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
|
|
||||||
import { shopApi, productApi, shoppingEventApi } from '../services/api';
|
|
||||||
|
|
||||||
const ShoppingEventForm: React.FC = () => {
|
|
||||||
const { id } = useParams<{ id: string }>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [shops, setShops] = useState<Shop[]>([]);
|
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [loadingEvent, setLoadingEvent] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
|
||||||
|
|
||||||
const isEditMode = Boolean(id);
|
|
||||||
|
|
||||||
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
|
|
||||||
});
|
|
||||||
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 fetchShoppingEvent = useCallback(async (eventId: number) => {
|
|
||||||
try {
|
|
||||||
setLoadingEvent(true);
|
|
||||||
const response = await shoppingEventApi.getById(eventId);
|
|
||||||
const event = response.data;
|
|
||||||
|
|
||||||
// Use the date directly if it's already in YYYY-MM-DD format, otherwise format it
|
|
||||||
let formattedDate = event.date;
|
|
||||||
if (event.date.includes('T') || event.date.length > 10) {
|
|
||||||
// If the date includes time or is longer than YYYY-MM-DD, extract just the date part
|
|
||||||
formattedDate = event.date.split('T')[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map products to the format we need
|
|
||||||
const mappedProducts = event.products.map(p => ({
|
|
||||||
product_id: p.id,
|
|
||||||
amount: p.amount,
|
|
||||||
price: p.price
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Calculate the sum of all products
|
|
||||||
const calculatedTotal = calculateTotal(mappedProducts);
|
|
||||||
|
|
||||||
// Check if existing total matches calculated total (with small tolerance for floating point)
|
|
||||||
const existingTotal = event.total_amount || 0;
|
|
||||||
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
|
|
||||||
|
|
||||||
setFormData({
|
|
||||||
shop_id: event.shop.id,
|
|
||||||
date: formattedDate,
|
|
||||||
total_amount: event.total_amount,
|
|
||||||
notes: event.notes || '',
|
|
||||||
products: []
|
|
||||||
});
|
|
||||||
|
|
||||||
setSelectedProducts(mappedProducts);
|
|
||||||
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching shopping event:', error);
|
|
||||||
setMessage('Error loading shopping event. Please try again.');
|
|
||||||
} finally {
|
|
||||||
setLoadingEvent(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchShops();
|
|
||||||
fetchProducts();
|
|
||||||
if (isEditMode && id) {
|
|
||||||
fetchShoppingEvent(parseInt(id));
|
|
||||||
}
|
|
||||||
}, [id, isEditMode, fetchShoppingEvent]);
|
|
||||||
|
|
||||||
// Update total amount whenever selectedProducts changes
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoCalculate) {
|
|
||||||
const calculatedTotal = calculateTotal(selectedProducts);
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
total_amount: calculatedTotal > 0 ? calculatedTotal : undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}, [selectedProducts, autoCalculate]);
|
|
||||||
|
|
||||||
const fetchShops = async () => {
|
|
||||||
try {
|
|
||||||
const response = await shopApi.getAll();
|
|
||||||
setShops(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching shops:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchProducts = async () => {
|
|
||||||
try {
|
|
||||||
const response = await productApi.getAll();
|
|
||||||
setProducts(response.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching products:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const addProductToEvent = () => {
|
|
||||||
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
|
||||||
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
|
||||||
setNewProductItem({ product_id: 0, amount: 1, price: 0 });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeProductFromEvent = (index: number) => {
|
|
||||||
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const editProductFromEvent = (index: number) => {
|
|
||||||
const productToEdit = selectedProducts[index];
|
|
||||||
// Load the product data into the input fields
|
|
||||||
setNewProductItem({
|
|
||||||
product_id: productToEdit.product_id,
|
|
||||||
amount: productToEdit.amount,
|
|
||||||
price: productToEdit.price
|
|
||||||
});
|
|
||||||
// Remove the item from the selected list
|
|
||||||
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setMessage('');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const eventData = {
|
|
||||||
...formData,
|
|
||||||
products: selectedProducts
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isEditMode) {
|
|
||||||
// Update existing event
|
|
||||||
console.log('Updating event data:', eventData);
|
|
||||||
await shoppingEventApi.update(parseInt(id!), eventData);
|
|
||||||
setMessage('Shopping event updated successfully!');
|
|
||||||
|
|
||||||
// Navigate back to shopping events list after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/shopping-events');
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
|
||||||
// Create new event
|
|
||||||
await shoppingEventApi.create(eventData);
|
|
||||||
setMessage('Shopping event created successfully!');
|
|
||||||
|
|
||||||
// Reset form for add mode
|
|
||||||
setFormData({
|
|
||||||
shop_id: 0,
|
|
||||||
date: new Date().toISOString().split('T')[0],
|
|
||||||
total_amount: undefined,
|
|
||||||
notes: '',
|
|
||||||
products: []
|
|
||||||
});
|
|
||||||
setSelectedProducts([]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Full error object:', error);
|
|
||||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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 ? ' 🌱' : '';
|
|
||||||
return `${product.name}${organicEmoji} ${weightInfo}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loadingEvent) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-center items-center h-64">
|
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto">
|
|
||||||
<div className="bg-white shadow rounded-lg">
|
|
||||||
<div className="px-4 py-5 sm:p-6">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
||||||
{isEditMode ? 'Edit Shopping Event' : 'Add New Event'}
|
|
||||||
</h3>
|
|
||||||
{isEditMode && (
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/shopping-events')}
|
|
||||||
className="text-gray-500 hover:text-gray-700"
|
|
||||||
>
|
|
||||||
← Back to Shopping Events
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{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 Products Section */}
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Add Products
|
|
||||||
</label>
|
|
||||||
<div className="flex space-x-2 mb-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
|
||||||
Product
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={newProductItem.product_id}
|
|
||||||
onChange={(e) => setNewProductItem({...newProductItem, product_id: parseInt(e.target.value)})}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
>
|
|
||||||
<option value={0}>Select a product</option>
|
|
||||||
{products.map(product => (
|
|
||||||
<option key={product.id} value={product.id}>
|
|
||||||
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category.name}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="w-24">
|
|
||||||
<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-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-24">
|
|
||||||
<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-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={addProductToEvent}
|
|
||||||
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selected Products List */}
|
|
||||||
{selectedProducts.length > 0 && (
|
|
||||||
<div className="bg-gray-50 rounded-md p-4">
|
|
||||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
|
||||||
{selectedProducts.map((item, index) => (
|
|
||||||
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{getProductName(item.product_id)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => editProductFromEvent(index)}
|
|
||||||
className="text-blue-500 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeProductFromEvent(index)}
|
|
||||||
className="text-red-500 hover:text-red-700"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Total Amount */}
|
|
||||||
<div>
|
|
||||||
<div className="flex justify-between items-center mb-2">
|
|
||||||
<label className="block text-sm font-medium text-gray-700">
|
|
||||||
Total Amount
|
|
||||||
</label>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<label className="relative inline-flex items-center cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={autoCalculate}
|
|
||||||
onChange={(e) => setAutoCalculate(e.target.checked)}
|
|
||||||
className="sr-only peer"
|
|
||||||
/>
|
|
||||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
|
||||||
</label>
|
|
||||||
<span className={`text-xs px-2 py-1 rounded ${autoCalculate ? 'text-green-600 bg-green-50' : 'text-gray-500 bg-gray-100'}`}>
|
|
||||||
Auto-calculated
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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});
|
|
||||||
setAutoCalculate(false); // Disable auto-calculation when manually editing
|
|
||||||
}}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-right"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
{autoCalculate
|
|
||||||
? "This field is automatically calculated from your selected items. You can manually edit it if needed."
|
|
||||||
: "Auto-calculation is disabled. You can manually enter the total amount or enable auto-calculation above."
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
</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 className="flex justify-end space-x-3">
|
|
||||||
{isEditMode && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => navigate('/shopping-events')}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
|
|
||||||
className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${
|
|
||||||
isEditMode
|
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
: 'w-full bg-blue-500 hover:bg-blue-700 font-bold py-2 px-4 focus:outline-none focus:shadow-outline'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{loading
|
|
||||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
|
||||||
: (isEditMode ? 'Update Shopping Event' : 'Create Shopping Event')
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ShoppingEventForm;
|
|
||||||
@@ -1,20 +1,54 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
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 ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
import AddShoppingEventModal from './AddShoppingEventModal';
|
||||||
|
|
||||||
const ShoppingEventList: React.FC = () => {
|
const ShoppingEventList: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
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 [deletingEvent, setDeletingEvent] = useState<ShoppingEvent | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
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 {
|
||||||
@@ -53,6 +87,189 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
setDeletingEvent(null);
|
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">
|
||||||
@@ -63,11 +280,11 @@ 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
|
<button
|
||||||
onClick={() => navigate('/shopping-events/new')}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
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>
|
||||||
@@ -89,67 +306,112 @@ 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>
|
</div>
|
||||||
<div className="text-right">
|
</th>
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<th
|
||||||
{new Date(event.date).toLocaleDateString()}
|
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"
|
||||||
</p>
|
onClick={() => handleSort('date')}
|
||||||
{event.total_amount && (
|
>
|
||||||
<p className="text-lg font-semibold text-green-600">
|
<div className="flex items-center">
|
||||||
${event.total_amount.toFixed(2)}
|
Date
|
||||||
</p>
|
{getSortIcon('date')}
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</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 && (
|
{event.products.length > 0 && (
|
||||||
<div className="mb-4">
|
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
|
<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" />
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
|
||||||
{event.products.map((product) => (
|
|
||||||
<div key={product.id} className="bg-gray-50 rounded px-3 py-2">
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{product.name} {product.organic ? '🌱' : ''}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
{product.amount} × ${product.price.toFixed(2)} = ${(product.amount * product.price).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</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">
|
|
||||||
<div className="text-gray-500">
|
|
||||||
<div>Event #{event.id} • Created {new Date(event.created_at).toLocaleDateString()}</div>
|
|
||||||
{event.updated_at && (
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
</svg>
|
||||||
Updated {new Date(event.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</td>
|
||||||
<div className="flex space-x-2">
|
<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
|
<button
|
||||||
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}
|
onClick={() => handleEdit(event)}
|
||||||
className="text-indigo-600 hover:text-indigo-900"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -159,22 +421,129 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</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 className="flex items-center justify-between mb-3">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleItemsClick(event, e)}
|
||||||
|
className={`text-sm ${
|
||||||
|
event.products.length > 0
|
||||||
|
? '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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex space-x-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(event)}
|
||||||
|
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(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
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Modals */}
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
isOpen={!!deletingEvent}
|
isOpen={!!deletingEvent}
|
||||||
onClose={handleCloseDeleteModal}
|
onClose={handleCloseDeleteModal}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
title="Delete Shopping Event"
|
title="Delete Shopping Event"
|
||||||
message={`Are you sure you want to delete this shopping event from ${deletingEvent?.shop.name}? This action cannot be undone.`}
|
message={`Are you sure you want to delete the shopping event at ${deletingEvent?.shop.name}? This action cannot be undone.`}
|
||||||
isLoading={deleteLoading}
|
isLoading={deleteLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddShoppingEventModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
onEventAdded={handleEventAdded}
|
||||||
|
editEvent={editingEvent}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
7
frontend/src/config/version.ts
Normal file
7
frontend/src/config/version.ts
Normal 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"
|
||||||
|
};
|
||||||
33
frontend/src/hooks/useBodyScrollLock.ts
Normal file
33
frontend/src/hooks/useBodyScrollLock.ts
Normal 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]);
|
||||||
|
};
|
||||||
26
frontend/src/hooks/useIsMobile.ts
Normal file
26
frontend/src/hooks/useIsMobile.ts
Normal 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;
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate } from '../types';
|
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, GroceryCategory, GroceryCategoryCreate, BrandInShop, BrandInShopCreate } from '../types';
|
||||||
|
|
||||||
const API_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 = {
|
const api = {
|
||||||
get: <T>(url: string): Promise<{ data: T }> =>
|
get: <T>(url: string): Promise<{ data: T }> =>
|
||||||
@@ -23,8 +26,10 @@ const api = {
|
|||||||
|
|
||||||
// Product API functions
|
// Product API functions
|
||||||
export const productApi = {
|
export const productApi = {
|
||||||
getAll: () => api.get<Product[]>('/products/'),
|
getAll: (showDeleted: boolean = false) => api.get<Product[]>(`/products/?show_deleted=${showDeleted}`),
|
||||||
getById: (id: number) => api.get<Product>(`/products/${id}`),
|
getById: (id: number) => api.get<Product>(`/products/${id}`),
|
||||||
|
getValidFromDate: (id: number) => api.get<{ valid_from: string }>(`/products/${id}/valid-from`),
|
||||||
|
getAvailableForShopping: (shoppingDate: string) => api.get<Product[]>(`/products/available-for-shopping/${shoppingDate}`),
|
||||||
create: (product: ProductCreate) => api.post<Product>('/products/', product),
|
create: (product: ProductCreate) => api.post<Product>('/products/', product),
|
||||||
update: (id: number, product: Partial<ProductCreate>) =>
|
update: (id: number, product: Partial<ProductCreate>) =>
|
||||||
api.put<Product>(`/products/${id}`, product),
|
api.put<Product>(`/products/${id}`, product),
|
||||||
@@ -51,6 +56,16 @@ export const brandApi = {
|
|||||||
delete: (id: number) => api.delete(`/brands/${id}`),
|
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
|
// Grocery Category API functions
|
||||||
export const groceryCategoryApi = {
|
export const groceryCategoryApi = {
|
||||||
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
|
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
|
||||||
@@ -70,20 +85,15 @@ export const shoppingEventApi = {
|
|||||||
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Grocery API functions
|
|
||||||
export const groceryApi = {
|
|
||||||
getAll: () => api.get<Grocery[]>('/groceries/'),
|
|
||||||
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
|
|
||||||
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
|
|
||||||
update: (id: number, grocery: Partial<GroceryCreate>) =>
|
|
||||||
api.put<Grocery>(`/groceries/${id}`, grocery),
|
|
||||||
delete: (id: number) => api.delete(`/groceries/${id}`),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Statistics API functions
|
// Statistics API functions
|
||||||
export const statsApi = {
|
export const statsApi = {
|
||||||
getCategories: () => api.get('/stats/categories'),
|
getCategories: () => api.get('/stats/categories'),
|
||||||
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;
|
||||||
@@ -20,41 +20,29 @@ export interface GroceryCategoryCreate {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Grocery {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
category_id: number;
|
|
||||||
created_at: string;
|
|
||||||
updated_at?: string;
|
|
||||||
category: GroceryCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GroceryCreate {
|
|
||||||
name: string;
|
|
||||||
category_id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Product {
|
export interface Product {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
grocery_id: number;
|
category_id: number;
|
||||||
grocery: Grocery;
|
category: GroceryCategory;
|
||||||
brand_id?: number;
|
brand_id?: number;
|
||||||
brand?: Brand;
|
brand?: Brand;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
|
deleted?: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductCreate {
|
export interface ProductCreate {
|
||||||
name: string;
|
name: string;
|
||||||
grocery_id: number;
|
category_id: number;
|
||||||
brand_id?: number;
|
brand_id?: number;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
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 {
|
||||||
@@ -76,18 +64,20 @@ export interface ProductInEvent {
|
|||||||
product_id: number;
|
product_id: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
discount: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductWithEventData {
|
export interface ProductWithEventData {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
grocery: Grocery;
|
category: GroceryCategory;
|
||||||
brand?: Brand;
|
brand?: Brand;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
weight_unit: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
price: number;
|
price: number;
|
||||||
|
discount: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingEvent {
|
export interface ShoppingEvent {
|
||||||
@@ -123,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;
|
||||||
|
}
|
||||||
102
frontend/src/utils/dateUtils.ts
Normal file
102
frontend/src/utils/dateUtils.ts
Normal 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
11
init-db.sql
Normal 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
|
||||||
127
test_temporal_logic.md
Normal file
127
test_temporal_logic.md
Normal 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)
|
||||||
Reference in New Issue
Block a user