- add grocery category
- add Dockerfile
This commit is contained in:
parent
6118415f05
commit
f88a931008
370
DOCKER_DEPLOYMENT.md
Normal file
370
DOCKER_DEPLOYMENT.md
Normal file
@ -0,0 +1,370 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
This guide explains how to deploy your Groceries/Product Tracker application using Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- Git (for cloning from your Gitea repository)
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. **Clone your repository from Gitea:**
|
||||
```bash
|
||||
git clone <your-gitea-repo-url>
|
||||
cd groceries
|
||||
```
|
||||
|
||||
2. **Set up environment variables:**
|
||||
```bash
|
||||
cp docker.env.example .env
|
||||
# Edit .env with your production values
|
||||
nano .env
|
||||
```
|
||||
|
||||
3. **Build and start all services:**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **Access your application:**
|
||||
- Frontend: http://localhost
|
||||
- Backend API: http://localhost:8000
|
||||
- API Documentation: http://localhost:8000/docs
|
||||
|
||||
## Architecture
|
||||
|
||||
The Docker Compose setup includes:
|
||||
|
||||
- **PostgreSQL Database** (port 5432)
|
||||
- **FastAPI Backend** (port 8000)
|
||||
- **React Frontend with Nginx** (port 80)
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
Create a `.env` file in the root directory:
|
||||
|
||||
```bash
|
||||
# Database Configuration
|
||||
DB_PASSWORD=your_secure_database_password
|
||||
|
||||
# Backend Configuration
|
||||
SECRET_KEY=your-super-secret-key-for-jwt-tokens-make-it-very-long-and-random
|
||||
DEBUG=False
|
||||
```
|
||||
|
||||
### Optional Configuration
|
||||
|
||||
You can override default ports by adding to your `.env` file:
|
||||
|
||||
```bash
|
||||
# Custom ports (optional)
|
||||
FRONTEND_PORT=3000
|
||||
BACKEND_PORT=8001
|
||||
DB_PORT=5433
|
||||
```
|
||||
|
||||
Then update the docker-compose.yml ports section accordingly.
|
||||
|
||||
## Deployment Commands
|
||||
|
||||
### Development Deployment
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker-compose up
|
||||
|
||||
# Start in background
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# View logs for specific service
|
||||
docker-compose logs -f backend
|
||||
```
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **Set production environment:**
|
||||
```bash
|
||||
export COMPOSE_FILE=docker-compose.yml
|
||||
export COMPOSE_PROJECT_NAME=groceries_prod
|
||||
```
|
||||
|
||||
2. **Use production environment file:**
|
||||
```bash
|
||||
cp docker.env.example .env.prod
|
||||
# Edit .env.prod with production values
|
||||
docker-compose --env-file .env.prod up -d
|
||||
```
|
||||
|
||||
3. **Run database migrations:**
|
||||
```bash
|
||||
docker-compose exec backend alembic upgrade head
|
||||
```
|
||||
|
||||
## Database Management
|
||||
|
||||
### Initial Setup
|
||||
|
||||
The database will be automatically created when you first start the services. To run migrations:
|
||||
|
||||
```bash
|
||||
# Run migrations
|
||||
docker-compose exec backend alembic upgrade head
|
||||
|
||||
# Create new migration (if you've made model changes)
|
||||
docker-compose exec backend alembic revision --autogenerate -m "Description"
|
||||
```
|
||||
|
||||
### Backup and Restore
|
||||
|
||||
```bash
|
||||
# Backup database
|
||||
docker-compose exec db pg_dump -U product_user product_tracker > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker-compose exec -T db psql -U product_user product_tracker < backup.sql
|
||||
```
|
||||
|
||||
## Service Management
|
||||
|
||||
### Individual Service Control
|
||||
|
||||
```bash
|
||||
# Restart specific service
|
||||
docker-compose restart backend
|
||||
|
||||
# Rebuild and restart service
|
||||
docker-compose up -d --build backend
|
||||
|
||||
# Scale services (if needed)
|
||||
docker-compose up -d --scale backend=2
|
||||
```
|
||||
|
||||
### Updating the Application
|
||||
|
||||
```bash
|
||||
# Pull latest code from Gitea
|
||||
git pull origin main
|
||||
|
||||
# Rebuild and restart services
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Monitoring and Logs
|
||||
|
||||
### View Logs
|
||||
|
||||
```bash
|
||||
# All services
|
||||
docker-compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose logs -f backend
|
||||
docker-compose logs -f frontend
|
||||
docker-compose logs -f db
|
||||
|
||||
# Last 100 lines
|
||||
docker-compose logs --tail=100 backend
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
|
||||
The services include health checks. Check status:
|
||||
|
||||
```bash
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### Resource Usage
|
||||
|
||||
```bash
|
||||
# View resource usage
|
||||
docker stats
|
||||
|
||||
# View specific containers
|
||||
docker stats groceries_backend groceries_frontend groceries_db
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Port conflicts:**
|
||||
```bash
|
||||
# Check what's using the port
|
||||
lsof -i :80
|
||||
lsof -i :8000
|
||||
lsof -i :5432
|
||||
|
||||
# Change ports in .env file or docker-compose.yml
|
||||
```
|
||||
|
||||
2. **Database connection issues:**
|
||||
```bash
|
||||
# Check database logs
|
||||
docker-compose logs db
|
||||
|
||||
# Test database connection
|
||||
docker-compose exec db psql -U product_user -d product_tracker -c "SELECT 1;"
|
||||
```
|
||||
|
||||
3. **Backend not starting:**
|
||||
```bash
|
||||
# Check backend logs
|
||||
docker-compose logs backend
|
||||
|
||||
# Exec into container to debug
|
||||
docker-compose exec backend bash
|
||||
```
|
||||
|
||||
4. **Frontend not loading:**
|
||||
```bash
|
||||
# Check nginx logs
|
||||
docker-compose logs frontend
|
||||
|
||||
# Verify build completed successfully
|
||||
docker-compose exec frontend ls -la /usr/share/nginx/html
|
||||
```
|
||||
|
||||
### Reset Everything
|
||||
|
||||
```bash
|
||||
# Stop and remove all containers, networks, and volumes
|
||||
docker-compose down -v
|
||||
|
||||
# Remove all images
|
||||
docker-compose down --rmi all
|
||||
|
||||
# Start fresh
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Production Security
|
||||
|
||||
1. **Change default passwords:**
|
||||
- Update `DB_PASSWORD` in `.env`
|
||||
- Generate a strong `SECRET_KEY`
|
||||
|
||||
2. **Use HTTPS:**
|
||||
- Add SSL certificates
|
||||
- Configure nginx for HTTPS
|
||||
- Update docker-compose.yml with SSL configuration
|
||||
|
||||
3. **Network security:**
|
||||
- Remove port mappings for internal services
|
||||
- Use Docker secrets for sensitive data
|
||||
- Configure firewall rules
|
||||
|
||||
4. **Regular updates:**
|
||||
- Keep Docker images updated
|
||||
- Update application dependencies
|
||||
- Monitor security advisories
|
||||
|
||||
### Example Production docker-compose.yml
|
||||
|
||||
For production, consider:
|
||||
|
||||
```yaml
|
||||
# Remove port mappings for internal services
|
||||
services:
|
||||
db:
|
||||
# Remove: ports: - "5432:5432"
|
||||
|
||||
backend:
|
||||
# Remove: ports: - "8000:8000"
|
||||
|
||||
frontend:
|
||||
ports:
|
||||
- "443:443" # HTTPS
|
||||
- "80:80" # HTTP redirect
|
||||
```
|
||||
|
||||
## Gitea Integration
|
||||
|
||||
### Automated Deployment
|
||||
|
||||
You can set up automated deployment from your Gitea repository:
|
||||
|
||||
1. **Create a webhook** in your Gitea repository
|
||||
2. **Set up a deployment script** on your server:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# deploy.sh
|
||||
cd /path/to/your/app
|
||||
git pull origin main
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
3. **Configure webhook** to trigger the deployment script
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
Consider setting up Gitea Actions or external CI/CD:
|
||||
|
||||
```yaml
|
||||
# .gitea/workflows/deploy.yml
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Deploy to server
|
||||
run: |
|
||||
# Your deployment commands
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Production Optimizations
|
||||
|
||||
1. **Use multi-stage builds** (already implemented)
|
||||
2. **Enable gzip compression** in nginx
|
||||
3. **Add caching headers** (already configured)
|
||||
4. **Use connection pooling** for database
|
||||
5. **Configure resource limits:**
|
||||
|
||||
```yaml
|
||||
services:
|
||||
backend:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 512M
|
||||
cpus: '0.5'
|
||||
```
|
||||
|
||||
## Backup Strategy
|
||||
|
||||
### Automated Backups
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# backup.sh
|
||||
DATE=$(date +%Y%m%d_%H%M%S)
|
||||
docker-compose exec -T db pg_dump -U product_user product_tracker > "backup_${DATE}.sql"
|
||||
# Upload to cloud storage or remote location
|
||||
```
|
||||
|
||||
Set up a cron job:
|
||||
```bash
|
||||
# Run daily at 2 AM
|
||||
0 2 * * * /path/to/backup.sh
|
||||
```
|
||||
|
||||
This setup provides a robust, production-ready deployment of your groceries application using Docker Compose!
|
||||
28
backend/Dockerfile
Normal file
28
backend/Dockerfile
Normal file
@ -0,0 +1,28 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create a non-root user
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8000
|
||||
|
||||
# Command to run the application
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@ -31,13 +31,16 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
||||
text("""
|
||||
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
|
||||
sep.amount, sep.price,
|
||||
g.id as grocery_id, g.name as grocery_name, g.category as grocery_category,
|
||||
g.id as grocery_id, g.name as grocery_name,
|
||||
g.created_at as grocery_created_at, g.updated_at as grocery_updated_at,
|
||||
gc.id as category_id, gc.name as category_name,
|
||||
gc.created_at as category_created_at, gc.updated_at as category_updated_at,
|
||||
b.id as brand_id, b.name as brand_name,
|
||||
b.created_at as brand_created_at, b.updated_at as brand_updated_at
|
||||
FROM products p
|
||||
JOIN shopping_event_products sep ON p.id = sep.product_id
|
||||
JOIN groceries g ON p.grocery_id = g.id
|
||||
JOIN grocery_categories gc ON g.category_id = gc.id
|
||||
LEFT JOIN brands b ON p.brand_id = b.id
|
||||
WHERE sep.shopping_event_id = :event_id
|
||||
"""),
|
||||
@ -47,12 +50,20 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
||||
# Convert to ProductWithEventData objects
|
||||
products_with_data = []
|
||||
for row in product_data:
|
||||
category = schemas.GroceryCategory(
|
||||
id=row.category_id,
|
||||
name=row.category_name,
|
||||
created_at=row.category_created_at,
|
||||
updated_at=row.category_updated_at
|
||||
)
|
||||
|
||||
grocery = schemas.Grocery(
|
||||
id=row.grocery_id,
|
||||
name=row.grocery_name,
|
||||
category=row.grocery_category,
|
||||
category_id=row.category_id,
|
||||
created_at=row.grocery_created_at,
|
||||
updated_at=row.grocery_updated_at
|
||||
updated_at=row.grocery_updated_at,
|
||||
category=category
|
||||
)
|
||||
|
||||
brand = None
|
||||
@ -261,9 +272,67 @@ def delete_brand(brand_id: int, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
return {"message": "Brand deleted successfully"}
|
||||
|
||||
# Grocery Category endpoints
|
||||
@app.post("/grocery-categories/", response_model=schemas.GroceryCategory)
|
||||
def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)):
|
||||
db_category = models.GroceryCategory(**category.dict())
|
||||
db.add(db_category)
|
||||
db.commit()
|
||||
db.refresh(db_category)
|
||||
return db_category
|
||||
|
||||
@app.get("/grocery-categories/", response_model=List[schemas.GroceryCategory])
|
||||
def read_grocery_categories(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
categories = db.query(models.GroceryCategory).offset(skip).limit(limit).all()
|
||||
return categories
|
||||
|
||||
@app.get("/grocery-categories/{category_id}", response_model=schemas.GroceryCategory)
|
||||
def read_grocery_category(category_id: int, db: Session = Depends(get_db)):
|
||||
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first()
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery category not found")
|
||||
return category
|
||||
|
||||
@app.put("/grocery-categories/{category_id}", response_model=schemas.GroceryCategory)
|
||||
def update_grocery_category(category_id: int, category_update: schemas.GroceryCategoryUpdate, db: Session = Depends(get_db)):
|
||||
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first()
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery category not found")
|
||||
|
||||
update_data = category_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(category, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(category)
|
||||
return category
|
||||
|
||||
@app.delete("/grocery-categories/{category_id}")
|
||||
def delete_grocery_category(category_id: int, db: Session = Depends(get_db)):
|
||||
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first()
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery category not found")
|
||||
|
||||
# Check if any groceries reference this category
|
||||
groceries_with_category = db.query(models.Grocery).filter(models.Grocery.category_id == category_id).first()
|
||||
if groceries_with_category:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete category: groceries are still associated with this category"
|
||||
)
|
||||
|
||||
db.delete(category)
|
||||
db.commit()
|
||||
return {"message": "Grocery category deleted successfully"}
|
||||
|
||||
# Grocery endpoints
|
||||
@app.post("/groceries/", response_model=schemas.Grocery)
|
||||
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
|
||||
# Validate category exists
|
||||
category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == grocery.category_id).first()
|
||||
if category is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery category not found")
|
||||
|
||||
db_grocery = models.Grocery(**grocery.dict())
|
||||
db.add(db_grocery)
|
||||
db.commit()
|
||||
@ -289,6 +358,13 @@ def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: S
|
||||
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)
|
||||
|
||||
|
||||
@ -28,16 +28,28 @@ class Brand(Base):
|
||||
# Relationships
|
||||
products = relationship("Product", back_populates="brand")
|
||||
|
||||
class GroceryCategory(Base):
|
||||
__tablename__ = "grocery_categories"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
groceries = relationship("Grocery", back_populates="category")
|
||||
|
||||
class Grocery(Base):
|
||||
__tablename__ = "groceries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
category = Column(String, nullable=False)
|
||||
category_id = Column(Integer, ForeignKey("grocery_categories.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
category = relationship("GroceryCategory", back_populates="groceries")
|
||||
products = relationship("Product", back_populates="grocery")
|
||||
|
||||
class Product(Base):
|
||||
|
||||
@ -20,22 +20,41 @@ class Brand(BrandBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Grocery Category schemas
|
||||
class GroceryCategoryBase(BaseModel):
|
||||
name: str
|
||||
|
||||
class GroceryCategoryCreate(GroceryCategoryBase):
|
||||
pass
|
||||
|
||||
class GroceryCategoryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
class GroceryCategory(GroceryCategoryBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Grocery schemas
|
||||
class GroceryBase(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
category_id: int
|
||||
|
||||
class GroceryCreate(GroceryBase):
|
||||
pass
|
||||
|
||||
class GroceryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: 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
|
||||
|
||||
@ -1,603 +0,0 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||
<mxGraphModel dx="577" dy="699" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="shop-event-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="43" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="300" y="470" as="sourcePoint"/>
|
||||
<mxPoint x="350" y="420" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="shop-event-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="960" y="399" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="event-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="40" target="99" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="620" y="310" as="sourcePoint"/>
|
||||
<mxPoint x="720" y="270" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="event-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="900" y="230" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" parent="1" source="161" target="102" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="1020" y="550" as="sourcePoint"/>
|
||||
<mxPoint x="720" y="290" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="product-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="390" y="160" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="390" y="440" width="180" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="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="2" vertex="1">
|
||||
<mxGeometry y="90" 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="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="144" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="21" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="21" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="23" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="21" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="15" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="17" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="15" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="39" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_events</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="580" y="150" width="180" height="240" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="40" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="39" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="41" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="42" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="43" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="44" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="45" value="shop_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="46" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="47" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="48" value="date: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="49" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="50" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="51" value="total_amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="52" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="53" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="52" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="54" value="notes: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="52" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="58" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="59" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="58" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="60" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="58" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="111" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="210" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="112" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="111" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="113" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="111" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="70" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="80" y="120" width="180" height="210" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="72" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="73" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="74" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="75" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="76" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="77" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="78" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="79" value="city: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="80" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="81" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="82" value="address: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="89" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="90" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="89" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="91" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="89" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="92" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;strokeColor=#b85450;" parent="70" vertex="1">
|
||||
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="93" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="92" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="94" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="92" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="95" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="1090" y="260" width="240" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
|
||||
<mxGeometry y="30" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="97" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="99" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="60" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="100" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="102" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="90" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="103" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="105" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="120" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="106" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="108" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="150" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="109" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="108" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="119" 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="80" y="440" width="180" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="120" 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="119" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="121" 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="120" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="122" 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="120" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="123" 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="119" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="124" 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="123" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="125" 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="123" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="140" value="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="138" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="141" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="141" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="143" value="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="141" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="160" 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="710" y="445" width="180" height="300" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="161" 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="160" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="162" 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="161" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="163" 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="161" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="164" 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="160" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="165" 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="164" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="166" 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="164" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="170" 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="160" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="171" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="170" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="172" 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;" parent="170" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="188" 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="160" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="189" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="188" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="190" 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="188" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="173" 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="160" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="174" 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="173" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="175" value="organic: BOOLEAN" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="173" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="176" 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="160" vertex="1">
|
||||
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="177" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="176" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="178" value="weight: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="176" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="179" 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="160" vertex="1">
|
||||
<mxGeometry y="210" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="180" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="179" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="181" value="weight_unit: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="179" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="182" 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="160" vertex="1">
|
||||
<mxGeometry y="240" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="183" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="182" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="184" 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="182" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="185" 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="160" vertex="1">
|
||||
<mxGeometry y="270" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="186" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="185" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="187" 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="185" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="191" 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="390" y="790" width="180" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="192" 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="191" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="193" 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="192" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="194" 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="192" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="195" 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="191" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="196" 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="195" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="197" 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="195" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="213" 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="191" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="214" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="213" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="215" 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="213" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="216" 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="191" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="217" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="216" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="218" 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="216" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="219" value="" style="endArrow=ERmany;html=1;rounded=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" parent="1" source="192" target="188" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="900" y="500" as="sourcePoint"/>
|
||||
<mxPoint x="1100" y="375" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="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="170" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="580" y="845" as="sourcePoint"/>
|
||||
<mxPoint x="670" y="560" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="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="120" target="144" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="580" y="495" as="sourcePoint"/>
|
||||
<mxPoint x="720" y="560" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@ -1,6 +1,6 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||
<mxGraphModel dx="1183" dy="699" 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="1848" dy="501" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
@ -451,81 +451,143 @@
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="131" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
||||
<mxCell id="131" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="90" y="700" width="180" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="132" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="131">
|
||||
<mxCell id="132" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="131" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="133" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="132">
|
||||
<mxCell id="133" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="134" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="132">
|
||||
<mxCell id="134" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="132" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="135" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="131">
|
||||
<mxCell id="135" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="136" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="135">
|
||||
<mxCell id="136" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="137" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="135">
|
||||
<mxCell id="137" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="135" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="131">
|
||||
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="138">
|
||||
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="140" value="category: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="138">
|
||||
<mxCell id="140" value="category_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="138" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="141" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="131">
|
||||
<mxCell id="141" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="141">
|
||||
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="141" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="143" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="141">
|
||||
<mxCell id="143" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="141" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="144" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="131">
|
||||
<mxCell id="144" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="145" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="144">
|
||||
<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;" vertex="1" parent="144">
|
||||
<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;" edge="1" parent="1" source="132" target="9">
|
||||
<mxCell id="147" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="132" target="9" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="280" y="535" as="sourcePoint"/>
|
||||
<mxPoint x="430" y="585" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="148" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="-210" y="715" width="180" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="149" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="148">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="150" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="152" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="154" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="156" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="155">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="155">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="158" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="158">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="160" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="158">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="149" target="138">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="270" y="785" as="sourcePoint"/>
|
||||
<mxPoint x="80" y="835" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
|
||||
73
docker-compose.yml
Normal file
73
docker-compose.yml
Normal file
@ -0,0 +1,73 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: groceries_db
|
||||
environment:
|
||||
POSTGRES_DB: product_tracker
|
||||
POSTGRES_USER: product_user
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD:-secure_password_123}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||
ports:
|
||||
- "5432:5432"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U product_user -d product_tracker"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
restart: unless-stopped
|
||||
|
||||
# FastAPI Backend
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: groceries_backend
|
||||
environment:
|
||||
DATABASE_URL: postgresql://product_user:${DB_PASSWORD:-secure_password_123}@db:5432/product_tracker
|
||||
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-this-in-production}
|
||||
ALGORITHM: HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: 30
|
||||
DEBUG: ${DEBUG:-False}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./backend:/app
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8000/docs"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# React Frontend
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
container_name: groceries_frontend
|
||||
ports:
|
||||
- "80:80"
|
||||
depends_on:
|
||||
- backend
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: groceries_network
|
||||
11
docker.env.example
Normal file
11
docker.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# Database Configuration
|
||||
DB_PASSWORD=secure_password_123
|
||||
|
||||
# Backend Configuration
|
||||
SECRET_KEY=your-super-secret-key-change-this-in-production-make-it-very-long-and-random
|
||||
DEBUG=False
|
||||
|
||||
# Optional: Override default ports if needed
|
||||
# FRONTEND_PORT=80
|
||||
# BACKEND_PORT=8000
|
||||
# DB_PORT=5432
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal file
@ -0,0 +1,31 @@
|
||||
# Build stage
|
||||
FROM node:18-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine
|
||||
|
||||
# Copy built app from build stage
|
||||
COPY --from=build /app/build /usr/share/nginx/html
|
||||
|
||||
# Copy nginx configuration
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
26
frontend/nginx.conf
Normal file
26
frontend/nginx.conf
Normal file
@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Handle React Router
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Handle static files
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ProductList from './components/ProductList';
|
||||
import ShopList from './components/ShopList';
|
||||
import BrandList from './components/BrandList';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import ProductList from './components/ProductList';
|
||||
import ShoppingEventList from './components/ShoppingEventList';
|
||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||
import BrandList from './components/BrandList';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import GroceryCategoryList from './components/GroceryCategoryList';
|
||||
|
||||
function Navigation() {
|
||||
const location = useLocation();
|
||||
@ -16,48 +17,81 @@ function Navigation() {
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-blue-600 text-white p-4">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Product Tracker
|
||||
</Link>
|
||||
<div className="space-x-4">
|
||||
<nav className="bg-blue-600 shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex space-x-8">
|
||||
<Link
|
||||
to="/"
|
||||
className={`px-3 py-2 rounded ${isActive('/') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/products"
|
||||
className={`px-3 py-2 rounded ${isActive('/products') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
to="/shopping-events"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/shopping-events')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
to="/groceries"
|
||||
className={`px-3 py-2 rounded ${isActive('/groceries') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Groceries
|
||||
Shopping Events
|
||||
</Link>
|
||||
<Link
|
||||
to="/shops"
|
||||
className={`px-3 py-2 rounded ${isActive('/shops') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/shops')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Shops
|
||||
</Link>
|
||||
<Link
|
||||
to="/products"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/products')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
to="/brands"
|
||||
className={`px-3 py-2 rounded ${isActive('/brands') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
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="/shopping-events"
|
||||
className={`px-3 py-2 rounded ${isActive('/shopping-events') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
Shopping Events
|
||||
Groceries
|
||||
</Link>
|
||||
<Link
|
||||
to="/categories"
|
||||
className={`inline-flex items-center px-1 pt-1 text-sm font-medium ${
|
||||
isActive('/categories')
|
||||
? 'text-white border-b-2 border-white'
|
||||
: 'text-blue-100 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Categories
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@ -67,19 +101,22 @@ function Navigation() {
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Navigation />
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<main className="py-10">
|
||||
<div className="max-w-7xl mx-auto sm:px-6 lg:px-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/products" element={<ProductList />} />
|
||||
<Route path="/groceries" element={<GroceryList />} />
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/brands" element={<BrandList />} />
|
||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
|
||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
||||
<Route path="/add-purchase" element={<ShoppingEventForm />} />
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/products" element={<ProductList />} />
|
||||
<Route path="/brands" element={<BrandList />} />
|
||||
<Route path="/groceries" element={<GroceryList />} />
|
||||
<Route path="/categories" element={<GroceryCategoryList />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</Router>
|
||||
|
||||
111
frontend/src/components/AddGroceryCategoryModal.tsx
Normal file
111
frontend/src/components/AddGroceryCategoryModal.tsx
Normal file
@ -0,0 +1,111 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GroceryCategory, GroceryCategoryCreate } from '../types';
|
||||
import { groceryCategoryApi } from '../services/api';
|
||||
|
||||
interface AddGroceryCategoryModalProps {
|
||||
category?: GroceryCategory | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ category, onClose }) => {
|
||||
const [formData, setFormData] = useState<GroceryCategoryCreate>({
|
||||
name: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const isEditMode = Boolean(category);
|
||||
|
||||
useEffect(() => {
|
||||
if (category) {
|
||||
setFormData({
|
||||
name: category.name
|
||||
});
|
||||
}
|
||||
}, [category]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
if (isEditMode && category) {
|
||||
await groceryCategoryApi.update(category.id, formData);
|
||||
setMessage('Category updated successfully!');
|
||||
} else {
|
||||
await groceryCategoryApi.create(formData);
|
||||
setMessage('Category created successfully!');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{isEditMode ? 'Edit Category' : 'Add New Category'}
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Category Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter category name"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading
|
||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||
: (isEditMode ? 'Update Category' : 'Create Category')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddGroceryCategoryModal;
|
||||
@ -1,159 +1,121 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { groceryApi } from '../services/api';
|
||||
import { Grocery } from '../types';
|
||||
import { Grocery, GroceryCreate, GroceryCategory } from '../types';
|
||||
import { groceryApi, groceryCategoryApi } from '../services/api';
|
||||
|
||||
interface AddGroceryModalProps {
|
||||
isOpen: boolean;
|
||||
grocery?: Grocery | null;
|
||||
onClose: () => void;
|
||||
onGroceryAdded: () => void;
|
||||
editGrocery?: Grocery | null;
|
||||
}
|
||||
|
||||
interface GroceryFormData {
|
||||
name: string;
|
||||
category: string;
|
||||
}
|
||||
|
||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => {
|
||||
const [formData, setFormData] = useState<GroceryFormData>({
|
||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) => {
|
||||
const [formData, setFormData] = useState<GroceryCreate>({
|
||||
name: '',
|
||||
category: ''
|
||||
category_id: 0
|
||||
});
|
||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const categories = [
|
||||
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry', 'Frozen',
|
||||
'Bakery', 'Beverages', 'Snacks', 'Health & Beauty', 'Household', 'Other'
|
||||
];
|
||||
const isEditMode = Boolean(grocery);
|
||||
|
||||
const isEditMode = !!editGrocery;
|
||||
|
||||
// Initialize form data when editing
|
||||
useEffect(() => {
|
||||
if (editGrocery) {
|
||||
fetchCategories();
|
||||
if (grocery) {
|
||||
setFormData({
|
||||
name: editGrocery.name,
|
||||
category: editGrocery.category
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
category: ''
|
||||
name: grocery.name,
|
||||
category_id: grocery.category_id
|
||||
});
|
||||
}
|
||||
setError('');
|
||||
}, [editGrocery, isOpen]);
|
||||
}, [grocery]);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await groceryCategoryApi.getAll();
|
||||
setCategories(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
setMessage('Error loading categories. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim() || !formData.category.trim()) {
|
||||
setError('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const groceryData = {
|
||||
name: formData.name.trim(),
|
||||
category: formData.category.trim()
|
||||
};
|
||||
|
||||
if (isEditMode && editGrocery) {
|
||||
await groceryApi.update(editGrocery.id, groceryData);
|
||||
if (isEditMode && grocery) {
|
||||
await groceryApi.update(grocery.id, formData);
|
||||
setMessage('Grocery updated successfully!');
|
||||
} else {
|
||||
await groceryApi.create(groceryData);
|
||||
await groceryApi.create(formData);
|
||||
setMessage('Grocery created successfully!');
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
category: ''
|
||||
});
|
||||
|
||||
onGroceryAdded();
|
||||
setTimeout(() => {
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(`Failed to ${isEditMode ? 'update' : 'add'} grocery. Please try again.`);
|
||||
console.error(`Error ${isEditMode ? 'updating' : 'adding'} grocery:`, err);
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving grocery:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
{isEditMode ? 'Edit Grocery' : 'Add New Grocery'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||
Grocery Name *
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Grocery Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
onChange={(e) => setFormData({...formData, name: e.target.value})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter grocery name"
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="e.g., Milk, Bread, Apples"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||
Category *
|
||||
<div className="mb-4">
|
||||
<label className="block text-gray-700 text-sm font-bold mb-2">
|
||||
Category
|
||||
</label>
|
||||
<select
|
||||
id="category"
|
||||
name="category"
|
||||
value={formData.category}
|
||||
onChange={handleChange}
|
||||
value={formData.category_id}
|
||||
onChange={(e) => setFormData({...formData, category_id: parseInt(e.target.value)})}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required
|
||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
{categories.map(cat => (
|
||||
<option key={cat} value={cat}>{cat}</option>
|
||||
<option value={0}>Select a category</option>
|
||||
{categories.map(category => (
|
||||
<option key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<div className="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@ -163,10 +125,13 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
disabled={loading || formData.category_id === 0}
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (isEditMode ? 'Updating...' : 'Adding...') : (isEditMode ? 'Update Grocery' : 'Add Grocery')}
|
||||
{loading
|
||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||
: (isEditMode ? 'Update Grocery' : 'Create Grocery')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@ -202,7 +202,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
<option value="">Select a grocery type</option>
|
||||
{groceries.map(grocery => (
|
||||
<option key={grocery.id} value={grocery.id}>
|
||||
{grocery.name} ({grocery.category})
|
||||
{grocery.name} ({grocery.category.name})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@ -102,7 +102,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/shopping-events')}
|
||||
onClick={() => navigate('/shopping-events/new')}
|
||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
||||
|
||||
156
frontend/src/components/GroceryCategoryList.tsx
Normal file
156
frontend/src/components/GroceryCategoryList.tsx
Normal file
@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { GroceryCategory } from '../types';
|
||||
import { groceryCategoryApi } from '../services/api';
|
||||
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
||||
|
||||
const GroceryCategoryList: React.FC = () => {
|
||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [message, setMessage] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<GroceryCategory | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
}, []);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groceryCategoryApi.getAll();
|
||||
setCategories(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
setMessage('Error loading categories. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this category?')) {
|
||||
try {
|
||||
await groceryCategoryApi.delete(id);
|
||||
setMessage('Category deleted successfully!');
|
||||
fetchCategories();
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting category:', error);
|
||||
if (error.response?.status === 400) {
|
||||
setMessage('Cannot delete category: groceries are still associated with this category.');
|
||||
} else {
|
||||
setMessage('Error deleting category. Please try again.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (category: GroceryCategory) => {
|
||||
setEditingCategory(category);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingCategory(null);
|
||||
fetchCategories();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="bg-white shadow rounded-lg">
|
||||
<div className="px-4 py-5 sm:p-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Grocery Categories
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No categories found. Add your first category!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(category.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddGroceryCategoryModal
|
||||
category={editingCategory}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroceryCategoryList;
|
||||
@ -1,85 +1,60 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Grocery } from '../types';
|
||||
import { groceryApi } from '../services/api';
|
||||
import AddGroceryModal from './AddGroceryModal';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
|
||||
const GroceryList: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [message, setMessage] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
||||
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroceries();
|
||||
|
||||
// Check if we should auto-open the modal
|
||||
if (searchParams.get('add') === 'true') {
|
||||
setIsModalOpen(true);
|
||||
// Remove the parameter from URL
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
}, []);
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch groceries');
|
||||
console.error('Error fetching groceries:', err);
|
||||
} catch (error) {
|
||||
console.error('Error fetching groceries:', error);
|
||||
setMessage('Error loading groceries. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroceryAdded = () => {
|
||||
fetchGroceries(); // Refresh the groceries list
|
||||
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 handleEditGrocery = (grocery: Grocery) => {
|
||||
const handleEdit = (grocery: Grocery) => {
|
||||
setEditingGrocery(grocery);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDeleteGrocery = (grocery: Grocery) => {
|
||||
setDeletingGrocery(grocery);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGrocery) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await groceryApi.delete(deletingGrocery.id);
|
||||
setDeletingGrocery(null);
|
||||
fetchGroceries(); // Refresh the groceries list
|
||||
} catch (err: any) {
|
||||
console.error('Error deleting grocery:', err);
|
||||
// Handle specific error message from backend
|
||||
if (err.response?.status === 400) {
|
||||
setError('Cannot delete grocery: products are still associated with this grocery');
|
||||
} else {
|
||||
setError('Failed to delete grocery. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
const handleModalClose = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingGrocery(null);
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setDeletingGrocery(null);
|
||||
fetchGroceries();
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@ -91,98 +66,95 @@ const GroceryList: React.FC = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||
<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 New Grocery
|
||||
Add Grocery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{groceries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
<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) => (
|
||||
<div key={grocery.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{grocery.name}</h3>
|
||||
<div className="flex space-x-2">
|
||||
<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={() => handleEditGrocery(grocery)}
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||
onClick={() => handleEdit(grocery)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteGrocery(grocery)}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
onClick={() => handleDelete(grocery.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm">
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{grocery.category}
|
||||
</span>
|
||||
</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(grocery.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{grocery.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(grocery.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
<AddGroceryModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onGroceryAdded={handleGroceryAdded}
|
||||
editGrocery={editingGrocery}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={!!deletingGrocery}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Grocery"
|
||||
message={`Are you sure you want to delete "${deletingGrocery?.name}"? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
grocery={editingGrocery}
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -149,7 +149,7 @@ const ProductList: React.FC = () => {
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-900">{product.grocery.name}</div>
|
||||
<div className="text-xs text-gray-500">{product.grocery.category}</div>
|
||||
<div className="text-xs text-gray-500">{product.grocery.category.name}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{product.brand ? product.brand.name : '-'}
|
||||
|
||||
@ -283,7 +283,7 @@ const ShoppingEventForm: React.FC = () => {
|
||||
<option value={0}>Select a product</option>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category.name}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
@ -66,7 +66,7 @@ const ShoppingEventList: React.FC = () => {
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
||||
<button
|
||||
onClick={() => navigate('/add-purchase')}
|
||||
onClick={() => navigate('/shopping-events/new')}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Event
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate } from '../types';
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate } from '../types';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
@ -51,6 +51,15 @@ export const brandApi = {
|
||||
delete: (id: number) => api.delete(`/brands/${id}`),
|
||||
};
|
||||
|
||||
// Grocery Category API functions
|
||||
export const groceryCategoryApi = {
|
||||
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
|
||||
getById: (id: number) => api.get<GroceryCategory>(`/grocery-categories/${id}`),
|
||||
create: (category: GroceryCategoryCreate) => api.post<GroceryCategory>('/grocery-categories/', category),
|
||||
update: (id: number, category: Partial<GroceryCategoryCreate>) => api.put<GroceryCategory>(`/grocery-categories/${id}`, category),
|
||||
delete: (id: number) => api.delete(`/grocery-categories/${id}`),
|
||||
};
|
||||
|
||||
// Shopping Event API functions
|
||||
export const shoppingEventApi = {
|
||||
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
|
||||
|
||||
@ -9,17 +9,29 @@ export interface BrandCreate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Grocery {
|
||||
export interface GroceryCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GroceryCategoryCreate {
|
||||
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: string;
|
||||
category_id: number;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user