add grocery to product
This commit is contained in:
@@ -26,14 +26,18 @@ app.add_middleware(
|
|||||||
|
|
||||||
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
||||||
"""Build a shopping event response with products from the association table"""
|
"""Build a shopping event response with products from the association table"""
|
||||||
# Get products with their event-specific data including brand information
|
# Get products with their event-specific data including grocery and brand information
|
||||||
product_data = db.execute(
|
product_data = db.execute(
|
||||||
text("""
|
text("""
|
||||||
SELECT p.id, p.name, p.category, p.organic, p.weight, p.weight_unit,
|
SELECT p.id, p.name, p.organic, p.weight, p.weight_unit,
|
||||||
sep.amount, sep.price, b.id as brand_id, b.name as brand_name,
|
sep.amount, sep.price,
|
||||||
|
g.id as grocery_id, g.name as grocery_name, g.category as grocery_category,
|
||||||
|
g.created_at as grocery_created_at, g.updated_at as grocery_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
|
b.created_at as brand_created_at, b.updated_at as brand_updated_at
|
||||||
FROM products p
|
FROM products p
|
||||||
JOIN shopping_event_products sep ON p.id = sep.product_id
|
JOIN shopping_event_products sep ON p.id = sep.product_id
|
||||||
|
JOIN groceries g ON p.grocery_id = g.id
|
||||||
LEFT JOIN brands b ON p.brand_id = b.id
|
LEFT JOIN brands b ON p.brand_id = b.id
|
||||||
WHERE sep.shopping_event_id = :event_id
|
WHERE sep.shopping_event_id = :event_id
|
||||||
"""),
|
"""),
|
||||||
@@ -43,6 +47,14 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
|||||||
# Convert to ProductWithEventData objects
|
# Convert to ProductWithEventData objects
|
||||||
products_with_data = []
|
products_with_data = []
|
||||||
for row in product_data:
|
for row in product_data:
|
||||||
|
grocery = schemas.Grocery(
|
||||||
|
id=row.grocery_id,
|
||||||
|
name=row.grocery_name,
|
||||||
|
category=row.grocery_category,
|
||||||
|
created_at=row.grocery_created_at,
|
||||||
|
updated_at=row.grocery_updated_at
|
||||||
|
)
|
||||||
|
|
||||||
brand = None
|
brand = None
|
||||||
if row.brand_id is not None:
|
if row.brand_id is not None:
|
||||||
brand = schemas.Brand(
|
brand = schemas.Brand(
|
||||||
@@ -56,7 +68,7 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
|||||||
schemas.ProductWithEventData(
|
schemas.ProductWithEventData(
|
||||||
id=row.id,
|
id=row.id,
|
||||||
name=row.name,
|
name=row.name,
|
||||||
category=row.category,
|
grocery=grocery,
|
||||||
brand=brand,
|
brand=brand,
|
||||||
organic=row.organic,
|
organic=row.organic,
|
||||||
weight=row.weight,
|
weight=row.weight,
|
||||||
@@ -85,6 +97,11 @@ def read_root():
|
|||||||
# Product endpoints
|
# Product endpoints
|
||||||
@app.post("/products/", response_model=schemas.Product)
|
@app.post("/products/", response_model=schemas.Product)
|
||||||
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
||||||
|
# Validate grocery exists
|
||||||
|
grocery = db.query(models.Grocery).filter(models.Grocery.id == product.grocery_id).first()
|
||||||
|
if grocery is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||||
|
|
||||||
# Validate brand exists if brand_id is provided
|
# Validate brand exists if brand_id is provided
|
||||||
if product.brand_id is not None:
|
if product.brand_id is not None:
|
||||||
brand = db.query(models.Brand).filter(models.Brand.id == product.brand_id).first()
|
brand = db.query(models.Brand).filter(models.Brand.id == product.brand_id).first()
|
||||||
@@ -115,8 +132,15 @@ def update_product(product_id: int, product_update: schemas.ProductUpdate, db: S
|
|||||||
if product is None:
|
if product is None:
|
||||||
raise HTTPException(status_code=404, detail="Product not found")
|
raise HTTPException(status_code=404, detail="Product not found")
|
||||||
|
|
||||||
# Validate brand exists if brand_id is being updated
|
|
||||||
update_data = product_update.dict(exclude_unset=True)
|
update_data = product_update.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
# Validate grocery exists if grocery_id is being updated
|
||||||
|
if 'grocery_id' in update_data:
|
||||||
|
grocery = db.query(models.Grocery).filter(models.Grocery.id == update_data['grocery_id']).first()
|
||||||
|
if grocery is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||||
|
|
||||||
|
# Validate brand exists if brand_id is being updated
|
||||||
if 'brand_id' in update_data and update_data['brand_id'] is not None:
|
if 'brand_id' in update_data and update_data['brand_id'] is not None:
|
||||||
brand = db.query(models.Brand).filter(models.Brand.id == update_data['brand_id']).first()
|
brand = db.query(models.Brand).filter(models.Brand.id == update_data['brand_id']).first()
|
||||||
if brand is None:
|
if brand is None:
|
||||||
@@ -237,6 +261,59 @@ def delete_brand(brand_id: int, db: Session = Depends(get_db)):
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Brand deleted successfully"}
|
return {"message": "Brand deleted successfully"}
|
||||||
|
|
||||||
|
# Grocery endpoints
|
||||||
|
@app.post("/groceries/", response_model=schemas.Grocery)
|
||||||
|
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
|
||||||
|
db_grocery = models.Grocery(**grocery.dict())
|
||||||
|
db.add(db_grocery)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_grocery)
|
||||||
|
return db_grocery
|
||||||
|
|
||||||
|
@app.get("/groceries/", response_model=List[schemas.Grocery])
|
||||||
|
def read_groceries(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
|
groceries = db.query(models.Grocery).offset(skip).limit(limit).all()
|
||||||
|
return groceries
|
||||||
|
|
||||||
|
@app.get("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
||||||
|
def read_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
||||||
|
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||||
|
if grocery is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||||
|
return grocery
|
||||||
|
|
||||||
|
@app.put("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
||||||
|
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)):
|
||||||
|
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||||
|
if grocery is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||||
|
|
||||||
|
update_data = grocery_update.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(grocery, field, value)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(grocery)
|
||||||
|
return grocery
|
||||||
|
|
||||||
|
@app.delete("/groceries/{grocery_id}")
|
||||||
|
def delete_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
||||||
|
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||||
|
if grocery is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||||
|
|
||||||
|
# Check if any products reference this grocery
|
||||||
|
products_with_grocery = db.query(models.Product).filter(models.Product.grocery_id == grocery_id).first()
|
||||||
|
if products_with_grocery:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot delete grocery: products are still associated with this grocery"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.delete(grocery)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Grocery deleted successfully"}
|
||||||
|
|
||||||
# Shopping Event endpoints
|
# Shopping Event endpoints
|
||||||
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
||||||
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
||||||
|
|||||||
@@ -28,12 +28,24 @@ class Brand(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
products = relationship("Product", back_populates="brand")
|
products = relationship("Product", back_populates="brand")
|
||||||
|
|
||||||
|
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)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
products = relationship("Product", back_populates="grocery")
|
||||||
|
|
||||||
class Product(Base):
|
class Product(Base):
|
||||||
__tablename__ = "products"
|
__tablename__ = "products"
|
||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, nullable=False, index=True)
|
name = Column(String, nullable=False, index=True)
|
||||||
category = Column(String, nullable=False)
|
grocery_id = Column(Integer, ForeignKey("groceries.id"), nullable=False)
|
||||||
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True)
|
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=True)
|
||||||
organic = Column(Boolean, default=False)
|
organic = Column(Boolean, default=False)
|
||||||
weight = Column(Float, nullable=True) # in grams or kg
|
weight = Column(Float, nullable=True) # in grams or kg
|
||||||
@@ -42,6 +54,7 @@ class Product(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
|
grocery = relationship("Grocery", back_populates="products")
|
||||||
brand = relationship("Brand", back_populates="products")
|
brand = relationship("Brand", back_populates="products")
|
||||||
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
|
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,30 @@ class Brand(BrandBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
# Grocery schemas
|
||||||
|
class GroceryBase(BaseModel):
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
|
||||||
|
class GroceryCreate(GroceryBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class GroceryUpdate(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
category: Optional[str] = None
|
||||||
|
|
||||||
|
class Grocery(GroceryBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
# Base schemas
|
# Base schemas
|
||||||
class ProductBase(BaseModel):
|
class ProductBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
category: str
|
grocery_id: int
|
||||||
brand_id: Optional[int] = None
|
brand_id: Optional[int] = None
|
||||||
organic: bool = False
|
organic: bool = False
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
@@ -34,7 +54,7 @@ class ProductCreate(ProductBase):
|
|||||||
|
|
||||||
class ProductUpdate(BaseModel):
|
class ProductUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
category: Optional[str] = None
|
grocery_id: Optional[int] = None
|
||||||
brand_id: Optional[int] = None
|
brand_id: Optional[int] = None
|
||||||
organic: Optional[bool] = None
|
organic: Optional[bool] = None
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
@@ -44,6 +64,7 @@ class Product(ProductBase):
|
|||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: Optional[datetime] = None
|
updated_at: Optional[datetime] = None
|
||||||
|
grocery: Grocery
|
||||||
brand: Optional[Brand] = None
|
brand: Optional[Brand] = None
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
@@ -80,7 +101,7 @@ class ProductInEvent(BaseModel):
|
|||||||
class ProductWithEventData(BaseModel):
|
class ProductWithEventData(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
name: str
|
name: str
|
||||||
category: str
|
grocery: Grocery
|
||||||
brand: Optional[Brand] = None
|
brand: Optional[Brand] = None
|
||||||
organic: bool
|
organic: bool
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<mxfile host="65bd71144e">
|
<mxfile host="65bd71144e">
|
||||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||||
<mxGraphModel dx="547" dy="520" 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="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>
|
<root>
|
||||||
<mxCell id="0"/>
|
<mxCell id="0"/>
|
||||||
<mxCell id="1" parent="0"/>
|
<mxCell id="1" parent="0"/>
|
||||||
@@ -10,51 +10,24 @@
|
|||||||
<mxPoint x="350" y="420" as="targetPoint"/>
|
<mxPoint x="350" y="420" as="targetPoint"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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="320" y="350" 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="43" target="99" edge="1">
|
<mxCell id="event-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="43" target="99" edge="1">
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
<mxPoint x="620" y="310" as="sourcePoint"/>
|
<mxPoint x="620" y="310" as="sourcePoint"/>
|
||||||
<mxPoint x="720" y="270" as="targetPoint"/>
|
<mxPoint x="720" y="270" as="targetPoint"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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="650" y="280" width="40" height="30" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1">
|
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1">
|
||||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
<mxPoint x="280" y="150" as="sourcePoint"/>
|
<mxPoint x="280" y="150" as="sourcePoint"/>
|
||||||
<mxPoint x="720" y="290" as="targetPoint"/>
|
<mxPoint x="720" y="290" as="targetPoint"/>
|
||||||
<Array as="points">
|
<Array as="points"/>
|
||||||
<mxPoint x="650" y="150"/>
|
|
||||||
<mxPoint x="650" y="290"/>
|
|
||||||
</Array>
|
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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="630" y="220" width="40" height="30" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="legend" value="" style="swimlane;fontStyle=1;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
|
|
||||||
<mxGeometry x="80" y="620" width="300" height="120" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="legend-title" value="Legend" style="text;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;fontStyle=1;" parent="legend" vertex="1">
|
|
||||||
<mxGeometry y="30" width="300" height="20" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="legend-pk" value="🔑 Primary Key (PK)" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="legend" vertex="1">
|
|
||||||
<mxGeometry y="50" width="300" height="20" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="legend-fk" value="🔗 Foreign Key (FK)" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="legend" vertex="1">
|
|
||||||
<mxGeometry y="70" width="300" height="20" as="geometry"/>
|
|
||||||
</mxCell>
|
|
||||||
<mxCell id="legend-relation" value="1:N = One-to-Many Relationship" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="legend" vertex="1">
|
|
||||||
<mxGeometry y="90" width="300" height="20" 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">
|
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
|
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="70" y="70" width="180" height="270" as="geometry"/>
|
<mxGeometry x="420" y="470" width="180" height="300" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
@@ -82,9 +55,22 @@
|
|||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="9" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
<mxCell id="128" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="2">
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
|
<mxCell id="129" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="128">
|
||||||
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="130" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="128">
|
||||||
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="9" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||||
|
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
<mxCell id="10" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
<mxCell id="10" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
@@ -96,7 +82,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="12" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
<mxCell id="12" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="13" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
|
<mxCell id="13" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="12" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -109,7 +95,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="18" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
<mxCell id="18" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="19" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="18" vertex="1">
|
<mxCell id="19" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="18" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -122,7 +108,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="36" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
<mxCell id="36" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||||
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
<mxGeometry y="210" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="37" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="36" vertex="1">
|
<mxCell id="37" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="36" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -135,7 +121,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<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="210" width="180" height="30" as="geometry"/>
|
<mxGeometry y="240" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -148,7 +134,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<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="240" width="180" height="30" as="geometry"/>
|
<mxGeometry y="270" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
@@ -161,7 +147,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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">
|
<mxCell id="39" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_events</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="420" y="280" width="180" height="240" as="geometry"/>
|
<mxGeometry x="420" y="110" width="180" height="240" as="geometry"/>
|
||||||
</mxCell>
|
</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">
|
<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"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
@@ -241,21 +227,21 @@
|
|||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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;" vertex="1" parent="39">
|
<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"/>
|
<mxGeometry y="210" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="112" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="111">
|
<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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="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;" vertex="1" parent="111">
|
<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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="70" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="70" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="70" y="380" width="180" height="210" as="geometry"/>
|
<mxGeometry x="80" y="80" width="180" height="210" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
|
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
@@ -403,6 +389,75 @@
|
|||||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
|
<mxCell id="114" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
||||||
|
<mxGeometry x="90" y="480" width="180" height="150" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="115" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="114">
|
||||||
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="116" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="115">
|
||||||
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="117" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="115">
|
||||||
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="118" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="114">
|
||||||
|
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="119" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="118">
|
||||||
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="120" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="118">
|
||||||
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="121" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="114">
|
||||||
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="122" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="121">
|
||||||
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="123" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="121">
|
||||||
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="124" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="114">
|
||||||
|
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="125" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="124">
|
||||||
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="126" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="124">
|
||||||
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="127" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" edge="1" parent="1" source="115" target="128">
|
||||||
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
|
<mxPoint x="610" y="525" as="sourcePoint"/>
|
||||||
|
<mxPoint x="820" y="315" as="targetPoint"/>
|
||||||
|
<Array as="points"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
|
<mxCell id="131" value="" style="edgeStyle=none;orthogonalLoop=1;jettySize=auto;html=1;" edge="1" parent="1">
|
||||||
|
<mxGeometry width="80" relative="1" as="geometry">
|
||||||
|
<mxPoint x="430" y="490" as="sourcePoint"/>
|
||||||
|
<mxPoint x="510" y="490" as="targetPoint"/>
|
||||||
|
<Array as="points"/>
|
||||||
|
</mxGeometry>
|
||||||
|
</mxCell>
|
||||||
</root>
|
</root>
|
||||||
</mxGraphModel>
|
</mxGraphModel>
|
||||||
</diagram>
|
</diagram>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import Dashboard from './components/Dashboard';
|
|||||||
import ProductList from './components/ProductList';
|
import ProductList from './components/ProductList';
|
||||||
import ShopList from './components/ShopList';
|
import ShopList from './components/ShopList';
|
||||||
import BrandList from './components/BrandList';
|
import BrandList from './components/BrandList';
|
||||||
|
import GroceryList from './components/GroceryList';
|
||||||
import ShoppingEventList from './components/ShoppingEventList';
|
import ShoppingEventList from './components/ShoppingEventList';
|
||||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||||
|
|
||||||
@@ -33,6 +34,12 @@ function Navigation() {
|
|||||||
>
|
>
|
||||||
Products
|
Products
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/groceries"
|
||||||
|
className={`px-3 py-2 rounded ${isActive('/groceries') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||||
|
>
|
||||||
|
Groceries
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/shops"
|
to="/shops"
|
||||||
className={`px-3 py-2 rounded ${isActive('/shops') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
className={`px-3 py-2 rounded ${isActive('/shops') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||||
@@ -66,6 +73,7 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/products" element={<ProductList />} />
|
<Route path="/products" element={<ProductList />} />
|
||||||
|
<Route path="/groceries" element={<GroceryList />} />
|
||||||
<Route path="/shops" element={<ShopList />} />
|
<Route path="/shops" element={<ShopList />} />
|
||||||
<Route path="/brands" element={<BrandList />} />
|
<Route path="/brands" element={<BrandList />} />
|
||||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||||
|
|||||||
179
frontend/src/components/AddGroceryModal.tsx
Normal file
179
frontend/src/components/AddGroceryModal.tsx
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { groceryApi } from '../services/api';
|
||||||
|
import { Grocery } from '../types';
|
||||||
|
|
||||||
|
interface AddGroceryModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
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>({
|
||||||
|
name: '',
|
||||||
|
category: ''
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const categories = [
|
||||||
|
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry', 'Frozen',
|
||||||
|
'Bakery', 'Beverages', 'Snacks', 'Health & Beauty', 'Household', 'Other'
|
||||||
|
];
|
||||||
|
|
||||||
|
const isEditMode = !!editGrocery;
|
||||||
|
|
||||||
|
// Initialize form data when editing
|
||||||
|
useEffect(() => {
|
||||||
|
if (editGrocery) {
|
||||||
|
setFormData({
|
||||||
|
name: editGrocery.name,
|
||||||
|
category: editGrocery.category
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
category: ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
}, [editGrocery, isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!formData.name.trim() || !formData.category.trim()) {
|
||||||
|
setError('Please fill in all required fields');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
|
||||||
|
const groceryData = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
category: formData.category.trim()
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isEditMode && editGrocery) {
|
||||||
|
await groceryApi.update(editGrocery.id, groceryData);
|
||||||
|
} else {
|
||||||
|
await groceryApi.create(groceryData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
category: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
onGroceryAdded();
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
setError(`Failed to ${isEditMode ? 'update' : 'add'} grocery. Please try again.`);
|
||||||
|
console.error(`Error ${isEditMode ? 'updating' : 'adding'} grocery:`, err);
|
||||||
|
} 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">
|
||||||
|
{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}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-gray-700">
|
||||||
|
Grocery Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="e.g., Milk, Bread, Apples"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||||
|
Category *
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="category"
|
||||||
|
name="category"
|
||||||
|
value={formData.category}
|
||||||
|
onChange={handleChange}
|
||||||
|
required
|
||||||
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
>
|
||||||
|
<option value="">Select a category</option>
|
||||||
|
{categories.map(cat => (
|
||||||
|
<option key={cat} value={cat}>{cat}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{loading ? (isEditMode ? 'Updating...' : 'Adding...') : (isEditMode ? 'Update Grocery' : 'Add Grocery')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AddGroceryModal;
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { productApi, brandApi } from '../services/api';
|
import { productApi, brandApi, groceryApi } from '../services/api';
|
||||||
import { Product, Brand } from '../types';
|
import { Product, Brand, Grocery } from '../types';
|
||||||
|
|
||||||
interface AddProductModalProps {
|
interface AddProductModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -11,7 +11,7 @@ interface AddProductModalProps {
|
|||||||
|
|
||||||
interface ProductFormData {
|
interface ProductFormData {
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
grocery_id?: number;
|
||||||
brand_id?: number;
|
brand_id?: number;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
@@ -21,27 +21,24 @@ interface ProductFormData {
|
|||||||
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
|
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
|
||||||
const [formData, setFormData] = useState<ProductFormData>({
|
const [formData, setFormData] = useState<ProductFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
category: '',
|
grocery_id: undefined,
|
||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
weight_unit: 'piece'
|
weight_unit: 'piece'
|
||||||
});
|
});
|
||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
|
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const categories = [
|
|
||||||
'Produce', 'Meat & Seafood', 'Dairy & Eggs', 'Pantry', 'Frozen',
|
|
||||||
'Bakery', 'Beverages', 'Snacks', 'Health & Beauty', 'Household', 'Other'
|
|
||||||
];
|
|
||||||
|
|
||||||
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
||||||
|
|
||||||
// Fetch brands when modal opens
|
// Fetch brands and groceries when modal opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchBrands();
|
fetchBrands();
|
||||||
|
fetchGroceries();
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -54,12 +51,21 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchGroceries = async () => {
|
||||||
|
try {
|
||||||
|
const response = await groceryApi.getAll();
|
||||||
|
setGroceries(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching groceries:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Populate form when editing
|
// Populate form when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editProduct) {
|
if (editProduct) {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: editProduct.name,
|
name: editProduct.name,
|
||||||
category: editProduct.category,
|
grocery_id: editProduct.grocery_id,
|
||||||
brand_id: editProduct.brand_id,
|
brand_id: editProduct.brand_id,
|
||||||
organic: editProduct.organic,
|
organic: editProduct.organic,
|
||||||
weight: editProduct.weight,
|
weight: editProduct.weight,
|
||||||
@@ -69,7 +75,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
// Reset form for adding new product
|
// Reset form for adding new product
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
category: '',
|
grocery_id: undefined,
|
||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
@@ -81,7 +87,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim() || !formData.category.trim()) {
|
if (!formData.name.trim() || !formData.grocery_id) {
|
||||||
setError('Please fill in all required fields with valid values');
|
setError('Please fill in all required fields with valid values');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -91,9 +97,12 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
const productData = {
|
const productData = {
|
||||||
...formData,
|
name: formData.name.trim(),
|
||||||
|
grocery_id: formData.grocery_id!,
|
||||||
|
brand_id: formData.brand_id || undefined,
|
||||||
|
organic: formData.organic,
|
||||||
weight: formData.weight || undefined,
|
weight: formData.weight || undefined,
|
||||||
brand_id: formData.brand_id || undefined
|
weight_unit: formData.weight_unit
|
||||||
};
|
};
|
||||||
|
|
||||||
if (editProduct) {
|
if (editProduct) {
|
||||||
@@ -107,7 +116,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
// Reset form
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
category: '',
|
grocery_id: undefined,
|
||||||
brand_id: undefined,
|
brand_id: undefined,
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
@@ -130,7 +139,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
...prev,
|
...prev,
|
||||||
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
|
[name]: type === 'checkbox' ? (e.target as HTMLInputElement).checked
|
||||||
: type === 'number' ? (value === '' ? undefined : Number(value))
|
: type === 'number' ? (value === '' ? undefined : Number(value))
|
||||||
: name === 'brand_id' ? (value === '' ? undefined : Number(value))
|
: name === 'brand_id' || name === 'grocery_id' ? (value === '' ? undefined : Number(value))
|
||||||
: value
|
: value
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -174,25 +183,27 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
placeholder="e.g., Organic Bananas"
|
placeholder="e.g., Whole Foods Organic Milk"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="grocery_id" className="block text-sm font-medium text-gray-700">
|
||||||
Category *
|
Grocery Type *
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
id="category"
|
id="grocery_id"
|
||||||
name="category"
|
name="grocery_id"
|
||||||
value={formData.category}
|
value={formData.grocery_id || ''}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required
|
required
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
||||||
>
|
>
|
||||||
<option value="">Select a category</option>
|
<option value="">Select a grocery type</option>
|
||||||
{categories.map(cat => (
|
{groceries.map(grocery => (
|
||||||
<option key={cat} value={cat}>{cat}</option>
|
<option key={grocery.id} value={grocery.id}>
|
||||||
|
{grocery.name} ({grocery.category})
|
||||||
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
190
frontend/src/components/GroceryList.tsx
Normal file
190
frontend/src/components/GroceryList.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
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 [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);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroceryAdded = () => {
|
||||||
|
fetchGroceries(); // Refresh the groceries list
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditGrocery = (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 = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingGrocery(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setDeletingGrocery(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
|
>
|
||||||
|
Add New Grocery
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
{groceries.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||||
|
{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">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditGrocery(grocery)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteGrocery(grocery)}
|
||||||
|
className="text-red-600 hover:text-red-900 text-sm"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center text-sm">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default GroceryList;
|
||||||
@@ -123,7 +123,7 @@ const ProductList: React.FC = () => {
|
|||||||
Name
|
Name
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Category
|
Grocery
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Brand
|
Brand
|
||||||
@@ -148,9 +148,8 @@ const ProductList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
<div className="text-sm text-gray-900">{product.grocery.name}</div>
|
||||||
{product.category}
|
<div className="text-xs text-gray-500">{product.grocery.category}</div>
|
||||||
</span>
|
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{product.brand ? product.brand.name : '-'}
|
{product.brand ? product.brand.name : '-'}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate } from '../types';
|
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate } from '../types';
|
||||||
|
|
||||||
const API_BASE_URL = 'http://localhost:8000';
|
const API_BASE_URL = 'http://localhost:8000';
|
||||||
|
|
||||||
@@ -61,6 +61,16 @@ export const shoppingEventApi = {
|
|||||||
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Grocery API functions
|
||||||
|
export const groceryApi = {
|
||||||
|
getAll: () => api.get<Grocery[]>('/groceries/'),
|
||||||
|
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
|
||||||
|
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
|
||||||
|
update: (id: number, grocery: Partial<GroceryCreate>) =>
|
||||||
|
api.put<Grocery>(`/groceries/${id}`, grocery),
|
||||||
|
delete: (id: number) => api.delete(`/groceries/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
// Statistics API functions
|
// Statistics API functions
|
||||||
export const statsApi = {
|
export const statsApi = {
|
||||||
getCategories: () => api.get('/stats/categories'),
|
getCategories: () => api.get('/stats/categories'),
|
||||||
|
|||||||
@@ -9,10 +9,24 @@ export interface BrandCreate {
|
|||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Product {
|
export interface Grocery {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GroceryCreate {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
grocery_id: number;
|
||||||
|
grocery: Grocery;
|
||||||
brand_id?: number;
|
brand_id?: number;
|
||||||
brand?: Brand;
|
brand?: Brand;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
@@ -24,7 +38,7 @@ export interface Product {
|
|||||||
|
|
||||||
export interface ProductCreate {
|
export interface ProductCreate {
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
grocery_id: number;
|
||||||
brand_id?: number;
|
brand_id?: number;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
@@ -55,7 +69,7 @@ export interface ProductInEvent {
|
|||||||
export interface ProductWithEventData {
|
export interface ProductWithEventData {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
grocery: Grocery;
|
||||||
brand?: Brand;
|
brand?: Brand;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user