brands-in-shops feature implemented
This commit is contained in:
parent
7037be370e
commit
2846bcbb1c
@ -272,6 +272,75 @@ def delete_brand(brand_id: int, db: Session = Depends(get_db)):
|
||||
db.commit()
|
||||
return {"message": "Brand deleted successfully"}
|
||||
|
||||
# BrandInShop endpoints
|
||||
@app.post("/brands-in-shops/", response_model=schemas.BrandInShop)
|
||||
def create_brand_in_shop(brand_in_shop: schemas.BrandInShopCreate, db: Session = Depends(get_db)):
|
||||
# Validate shop exists
|
||||
shop = db.query(models.Shop).filter(models.Shop.id == brand_in_shop.shop_id).first()
|
||||
if shop is None:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
|
||||
# Validate brand exists
|
||||
brand = db.query(models.Brand).filter(models.Brand.id == brand_in_shop.brand_id).first()
|
||||
if brand is None:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
|
||||
# Check if this combination already exists
|
||||
existing = db.query(models.BrandInShop).filter(
|
||||
models.BrandInShop.shop_id == brand_in_shop.shop_id,
|
||||
models.BrandInShop.brand_id == brand_in_shop.brand_id
|
||||
).first()
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail="This brand is already associated with this shop")
|
||||
|
||||
db_brand_in_shop = models.BrandInShop(**brand_in_shop.dict())
|
||||
db.add(db_brand_in_shop)
|
||||
db.commit()
|
||||
db.refresh(db_brand_in_shop)
|
||||
return db_brand_in_shop
|
||||
|
||||
@app.get("/brands-in-shops/", response_model=List[schemas.BrandInShop])
|
||||
def read_brands_in_shops(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
brands_in_shops = db.query(models.BrandInShop).offset(skip).limit(limit).all()
|
||||
return brands_in_shops
|
||||
|
||||
@app.get("/brands-in-shops/shop/{shop_id}", response_model=List[schemas.BrandInShop])
|
||||
def read_brands_in_shop(shop_id: int, db: Session = Depends(get_db)):
|
||||
# Validate shop exists
|
||||
shop = db.query(models.Shop).filter(models.Shop.id == shop_id).first()
|
||||
if shop is None:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
|
||||
brands_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.shop_id == shop_id).all()
|
||||
return brands_in_shop
|
||||
|
||||
@app.get("/brands-in-shops/brand/{brand_id}", response_model=List[schemas.BrandInShop])
|
||||
def read_shops_with_brand(brand_id: int, db: Session = Depends(get_db)):
|
||||
# Validate brand exists
|
||||
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
|
||||
if brand is None:
|
||||
raise HTTPException(status_code=404, detail="Brand not found")
|
||||
|
||||
shops_with_brand = db.query(models.BrandInShop).filter(models.BrandInShop.brand_id == brand_id).all()
|
||||
return shops_with_brand
|
||||
|
||||
@app.get("/brands-in-shops/{brand_in_shop_id}", response_model=schemas.BrandInShop)
|
||||
def read_brand_in_shop(brand_in_shop_id: int, db: Session = Depends(get_db)):
|
||||
brand_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.id == brand_in_shop_id).first()
|
||||
if brand_in_shop is None:
|
||||
raise HTTPException(status_code=404, detail="Brand in shop association not found")
|
||||
return brand_in_shop
|
||||
|
||||
@app.delete("/brands-in-shops/{brand_in_shop_id}")
|
||||
def delete_brand_in_shop(brand_in_shop_id: int, db: Session = Depends(get_db)):
|
||||
brand_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.id == brand_in_shop_id).first()
|
||||
if brand_in_shop is None:
|
||||
raise HTTPException(status_code=404, detail="Brand in shop association not found")
|
||||
|
||||
db.delete(brand_in_shop)
|
||||
db.commit()
|
||||
return {"message": "Brand in shop association deleted successfully"}
|
||||
|
||||
# Grocery Category endpoints
|
||||
@app.post("/grocery-categories/", response_model=schemas.GroceryCategory)
|
||||
def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)):
|
||||
|
||||
@ -17,6 +17,19 @@ shopping_event_products = Table(
|
||||
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
|
||||
)
|
||||
|
||||
class BrandInShop(Base):
|
||||
__tablename__ = "brands_in_shops"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
|
||||
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
shop = relationship("Shop", back_populates="brands_in_shop")
|
||||
brand = relationship("Brand", back_populates="shops_with_brand")
|
||||
|
||||
class Brand(Base):
|
||||
__tablename__ = "brands"
|
||||
|
||||
@ -27,6 +40,7 @@ class Brand(Base):
|
||||
|
||||
# Relationships
|
||||
products = relationship("Product", back_populates="brand")
|
||||
shops_with_brand = relationship("BrandInShop", back_populates="brand")
|
||||
|
||||
class GroceryCategory(Base):
|
||||
__tablename__ = "grocery_categories"
|
||||
@ -82,6 +96,7 @@ class Shop(Base):
|
||||
|
||||
# Relationships
|
||||
shopping_events = relationship("ShoppingEvent", back_populates="shop")
|
||||
brands_in_shop = relationship("BrandInShop", back_populates="shop")
|
||||
|
||||
class ShoppingEvent(Base):
|
||||
__tablename__ = "shopping_events"
|
||||
|
||||
@ -20,6 +20,28 @@ class Brand(BrandBase):
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# BrandInShop schemas
|
||||
class BrandInShopBase(BaseModel):
|
||||
shop_id: int
|
||||
brand_id: int
|
||||
|
||||
class BrandInShopCreate(BrandInShopBase):
|
||||
pass
|
||||
|
||||
class BrandInShopUpdate(BaseModel):
|
||||
shop_id: Optional[int] = None
|
||||
brand_id: Optional[int] = None
|
||||
|
||||
class BrandInShop(BrandInShopBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
shop: "Shop"
|
||||
brand: "Brand"
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Grocery Category schemas
|
||||
class GroceryCategoryBase(BaseModel):
|
||||
name: str
|
||||
@ -168,4 +190,7 @@ class ShopStats(BaseModel):
|
||||
shop_name: str
|
||||
total_spent: float
|
||||
visit_count: int
|
||||
avg_per_visit: float
|
||||
avg_per_visit: float
|
||||
|
||||
# Update forward references
|
||||
BrandInShop.model_rebuild()
|
||||
@ -1,6 +1,6 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||
<mxGraphModel dx="1848" dy="501" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<mxGraphModel dx="1720" dy="739" 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"/>
|
||||
@ -58,7 +58,7 @@
|
||||
<mxCell id="128" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="129" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="128" vertex="1">
|
||||
<mxCell id="129" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="128" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
@ -71,7 +71,7 @@
|
||||
<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="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
@ -390,7 +390,7 @@
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="114" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="90" y="480" width="180" height="150" as="geometry"/>
|
||||
<mxGeometry x="-430" y="414" width="180" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="115" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="114" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
@ -483,7 +483,7 @@
|
||||
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="131" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
|
||||
<mxCell id="139" value="FK" 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>
|
||||
@ -526,68 +526,150 @@
|
||||
<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">
|
||||
<mxCell id="148" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="-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">
|
||||
<mxCell id="149" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="148" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="150" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
|
||||
<mxCell id="150" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="149" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
|
||||
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="149" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="152" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
||||
<mxCell id="152" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="148" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
|
||||
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="152" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="154" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
|
||||
<mxCell id="154" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="152" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
||||
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="148" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="156" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="155">
|
||||
<mxCell id="156" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="155" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="155">
|
||||
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="155" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="158" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
||||
<mxCell id="158" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="148" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="158">
|
||||
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="158" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="160" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="158">
|
||||
<mxCell id="160" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="158" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="149" target="138">
|
||||
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="149" target="138" edge="1">
|
||||
<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>
|
||||
<mxCell id="199" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" edge="1" parent="1" source="71" target="187">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="280" y="755" as="sourcePoint"/>
|
||||
<mxPoint x="430" y="615" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="200" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" edge="1" parent="1" source="115" target="190">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="90" y="135" as="sourcePoint"/>
|
||||
<mxPoint x="-21" y="352" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="183" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands_in_shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
||||
<mxGeometry x="-180" y="220" width="180" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="184" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="183">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="185" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="184">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="186" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="184">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="187" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="183">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="188" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="187">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="189" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: nowrap;">shop_id: INTEGER</span>" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="187">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="190" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="183">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="191" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="190">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="192" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="190">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="193" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="183">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="194" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="193">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="195" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="193">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="196" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="183">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="197" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="196">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="198" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="196">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { shopApi } from '../services/api';
|
||||
import { Shop } from '../types';
|
||||
import { shopApi, brandApi, brandInShopApi } from '../services/api';
|
||||
import { Shop, Brand, BrandInShop } from '../types';
|
||||
|
||||
interface AddShopModalProps {
|
||||
isOpen: boolean;
|
||||
@ -13,32 +13,70 @@ interface ShopFormData {
|
||||
name: string;
|
||||
city: string;
|
||||
address?: string;
|
||||
selectedBrands: number[];
|
||||
}
|
||||
|
||||
const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdded, editShop }) => {
|
||||
const [formData, setFormData] = useState<ShopFormData>({
|
||||
name: '',
|
||||
city: '',
|
||||
address: ''
|
||||
address: '',
|
||||
selectedBrands: []
|
||||
});
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const isEditMode = !!editShop;
|
||||
|
||||
// Load brands when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchBrands();
|
||||
if (editShop) {
|
||||
loadShopBrands(editShop.id);
|
||||
}
|
||||
}
|
||||
}, [isOpen, editShop]);
|
||||
|
||||
const fetchBrands = async () => {
|
||||
try {
|
||||
const response = await brandApi.getAll();
|
||||
setBrands(response.data);
|
||||
} catch (err) {
|
||||
console.error('Error fetching brands:', err);
|
||||
setError('Failed to load brands. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const loadShopBrands = async (shopId: number) => {
|
||||
try {
|
||||
const response = await brandInShopApi.getByShop(shopId);
|
||||
const brandIds = response.data.map((brandInShop: BrandInShop) => brandInShop.brand_id);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedBrands: brandIds
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Error loading shop brands:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize form data when editing
|
||||
useEffect(() => {
|
||||
if (editShop) {
|
||||
setFormData({
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: editShop.name,
|
||||
city: editShop.city,
|
||||
address: editShop.address || ''
|
||||
});
|
||||
}));
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
city: '',
|
||||
address: ''
|
||||
address: '',
|
||||
selectedBrands: []
|
||||
});
|
||||
}
|
||||
setError('');
|
||||
@ -86,17 +124,48 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
||||
address: trimmedAddress && trimmedAddress.length > 0 ? trimmedAddress : null
|
||||
};
|
||||
|
||||
let shopId: number;
|
||||
|
||||
if (isEditMode && editShop) {
|
||||
await shopApi.update(editShop.id, shopData);
|
||||
const updatedShop = await shopApi.update(editShop.id, shopData);
|
||||
shopId = editShop.id;
|
||||
} else {
|
||||
await shopApi.create(shopData);
|
||||
const newShop = await shopApi.create(shopData);
|
||||
shopId = newShop.data.id;
|
||||
}
|
||||
|
||||
// Handle brand associations
|
||||
if (isEditMode && editShop) {
|
||||
// Get existing brand associations
|
||||
const existingBrands = await brandInShopApi.getByShop(editShop.id);
|
||||
const existingBrandIds = existingBrands.data.map(b => b.brand_id);
|
||||
|
||||
// Remove brands that are no longer selected
|
||||
for (const brandInShop of existingBrands.data) {
|
||||
if (!formData.selectedBrands.includes(brandInShop.brand_id)) {
|
||||
await brandInShopApi.delete(brandInShop.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new brand associations
|
||||
for (const brandId of formData.selectedBrands) {
|
||||
if (!existingBrandIds.includes(brandId)) {
|
||||
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Create new brand associations for new shop
|
||||
for (const brandId of formData.selectedBrands) {
|
||||
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
city: '',
|
||||
address: ''
|
||||
address: '',
|
||||
selectedBrands: []
|
||||
});
|
||||
|
||||
onShopAdded();
|
||||
@ -117,11 +186,20 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBrandToggle = (brandId: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
selectedBrands: prev.selectedBrands.includes(brandId)
|
||||
? prev.selectedBrands.filter(id => id !== brandId)
|
||||
: [...prev.selectedBrands, brandId]
|
||||
}));
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
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="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white max-h-[80vh] overflow-y-auto">
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
@ -191,6 +269,34 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Available Brands (Optional)
|
||||
</label>
|
||||
<div className="max-h-40 overflow-y-auto border border-gray-300 rounded-md p-3 bg-gray-50">
|
||||
{brands.length === 0 ? (
|
||||
<p className="text-sm text-gray-500">Loading brands...</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{brands.map(brand => (
|
||||
<label key={brand.id} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.selectedBrands.includes(brand.id)}
|
||||
onChange={() => handleBrandToggle(brand.id)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-900">{brand.name}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Select the brands that are available in this shop
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent } from '../types';
|
||||
import { shopApi, productApi, shoppingEventApi } from '../services/api';
|
||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent, BrandInShop } from '../types';
|
||||
import { shopApi, productApi, shoppingEventApi, brandInShopApi } from '../services/api';
|
||||
|
||||
interface AddShoppingEventModalProps {
|
||||
isOpen: boolean;
|
||||
@ -17,6 +17,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
}) => {
|
||||
const [shops, setShops] = useState<Shop[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [shopBrands, setShopBrands] = useState<BrandInShop[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
@ -160,6 +161,30 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const fetchShopBrands = async (shopId: number) => {
|
||||
if (shopId === 0) {
|
||||
setShopBrands([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await brandInShopApi.getByShop(shopId);
|
||||
setShopBrands(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching shop brands:', error);
|
||||
setShopBrands([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to load shop brands when shop selection changes
|
||||
useEffect(() => {
|
||||
if (formData.shop_id > 0) {
|
||||
fetchShopBrands(formData.shop_id);
|
||||
} else {
|
||||
setShopBrands([]);
|
||||
}
|
||||
}, [formData.shop_id]);
|
||||
|
||||
const addProductToEvent = () => {
|
||||
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
||||
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
||||
@ -224,6 +249,23 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
return `${product.name}${organicEmoji} ${weightInfo}`;
|
||||
};
|
||||
|
||||
// Filter products based on selected shop's brands
|
||||
const getFilteredProducts = () => {
|
||||
// If no shop is selected or shop has no brands, show all products
|
||||
if (formData.shop_id === 0 || shopBrands.length === 0) {
|
||||
return products;
|
||||
}
|
||||
|
||||
// Get brand IDs available in the selected shop
|
||||
const availableBrandIds = shopBrands.map(sb => sb.brand_id);
|
||||
|
||||
// Filter products to only show those with brands available in the shop
|
||||
// Also include products without brands (brand_id is null/undefined)
|
||||
return products.filter(product =>
|
||||
!product.brand_id || availableBrandIds.includes(product.brand_id)
|
||||
);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@ -306,7 +348,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
>
|
||||
<option value={0}>Select a product</option>
|
||||
{Object.entries(
|
||||
products.reduce((groups, product) => {
|
||||
getFilteredProducts().reduce((groups, product) => {
|
||||
const category = product.grocery.category.name;
|
||||
if (!groups[category]) {
|
||||
groups[category] = [];
|
||||
@ -329,6 +371,14 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{formData.shop_id > 0 && (
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{shopBrands.length === 0
|
||||
? `Showing all ${products.length} products (no brand restrictions for this shop)`
|
||||
: `Showing ${getFilteredProducts().length} of ${products.length} products (filtered by shop's available brands)`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-24">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Shop } from '../types';
|
||||
import { shopApi } from '../services/api';
|
||||
import { Shop, BrandInShop } from '../types';
|
||||
import { shopApi, brandInShopApi } from '../services/api';
|
||||
import AddShopModal from './AddShopModal';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
|
||||
@ -16,6 +16,10 @@ const ShopList: React.FC = () => {
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [sortField, setSortField] = useState<keyof Shop>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [hoveredShop, setHoveredShop] = useState<Shop | null>(null);
|
||||
const [showBrandsPopup, setShowBrandsPopup] = useState(false);
|
||||
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
|
||||
const [shopBrands, setShopBrands] = useState<Record<number, BrandInShop[]>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchShops();
|
||||
@ -28,11 +32,35 @@ const ShopList: React.FC = () => {
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Handle clicking outside popup to close it
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (showBrandsPopup && !target.closest('.brands-popup') && !target.closest('.brands-cell')) {
|
||||
setShowBrandsPopup(false);
|
||||
setHoveredShop(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (showBrandsPopup) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showBrandsPopup]);
|
||||
|
||||
const fetchShops = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await shopApi.getAll();
|
||||
setShops(response.data);
|
||||
|
||||
// Load brands for all shops
|
||||
for (const shop of response.data) {
|
||||
loadShopBrands(shop.id);
|
||||
}
|
||||
} catch (err) {
|
||||
setError('Failed to fetch shops');
|
||||
console.error('Error fetching shops:', err);
|
||||
@ -41,6 +69,18 @@ const ShopList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const loadShopBrands = async (shopId: number) => {
|
||||
try {
|
||||
const response = await brandInShopApi.getByShop(shopId);
|
||||
setShopBrands(prev => ({
|
||||
...prev,
|
||||
[shopId]: response.data
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Error loading shop brands:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShopAdded = () => {
|
||||
fetchShops(); // Refresh the shops list
|
||||
};
|
||||
@ -79,6 +119,66 @@ const ShopList: React.FC = () => {
|
||||
setDeletingShop(null);
|
||||
};
|
||||
|
||||
const handleBrandsHover = (shop: Shop, mouseEvent: React.MouseEvent) => {
|
||||
const brands = shopBrands[shop.id] || [];
|
||||
if (brands.length === 0) return;
|
||||
|
||||
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||
const popupWidth = 300;
|
||||
const popupHeight = 200;
|
||||
|
||||
let x = mouseEvent.clientX + 10;
|
||||
let y = mouseEvent.clientY - 10;
|
||||
|
||||
// Adjust if popup would go off screen
|
||||
if (x + popupWidth > window.innerWidth) {
|
||||
x = mouseEvent.clientX - popupWidth - 10;
|
||||
}
|
||||
if (y + popupHeight > window.innerHeight) {
|
||||
y = mouseEvent.clientY - popupHeight + 10;
|
||||
}
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
setHoveredShop(shop);
|
||||
setPopupPosition({ x, y });
|
||||
setShowBrandsPopup(true);
|
||||
};
|
||||
|
||||
const handleBrandsLeave = () => {
|
||||
setShowBrandsPopup(false);
|
||||
setHoveredShop(null);
|
||||
};
|
||||
|
||||
const handleBrandsClick = (shop: Shop, mouseEvent: React.MouseEvent) => {
|
||||
const brands = shopBrands[shop.id] || [];
|
||||
if (brands.length === 0) return;
|
||||
|
||||
mouseEvent.stopPropagation();
|
||||
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||
const popupWidth = 300;
|
||||
const popupHeight = 200;
|
||||
|
||||
let x = mouseEvent.clientX + 10;
|
||||
let y = mouseEvent.clientY - 10;
|
||||
|
||||
// Adjust if popup would go off screen
|
||||
if (x + popupWidth > window.innerWidth) {
|
||||
x = mouseEvent.clientX - popupWidth - 10;
|
||||
}
|
||||
if (y + popupHeight > window.innerHeight) {
|
||||
y = mouseEvent.clientY - popupHeight + 10;
|
||||
}
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
setHoveredShop(shop);
|
||||
setPopupPosition({ x, y });
|
||||
setShowBrandsPopup(true);
|
||||
};
|
||||
|
||||
const handleSort = (field: keyof Shop) => {
|
||||
if (field === sortField) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
@ -197,6 +297,9 @@ const ShopList: React.FC = () => {
|
||||
{getSortIcon('address')}
|
||||
</div>
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Brands
|
||||
</th>
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||
onClick={() => handleSort('created_at')}
|
||||
@ -225,6 +328,28 @@ const ShopList: React.FC = () => {
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{shop.address || '-'}
|
||||
</td>
|
||||
<td
|
||||
className={`brands-cell px-6 py-4 whitespace-nowrap text-sm ${
|
||||
(shopBrands[shop.id]?.length || 0) > 0
|
||||
? 'text-blue-600 hover:text-blue-800 cursor-pointer hover:bg-blue-50'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
onMouseEnter={(e) => handleBrandsHover(shop, e)}
|
||||
onMouseLeave={handleBrandsLeave}
|
||||
onClick={(e) => handleBrandsClick(shop, e)}
|
||||
title={(shopBrands[shop.id]?.length || 0) > 0 ? 'Click to view brands' : ''}
|
||||
>
|
||||
{(shopBrands[shop.id]?.length || 0) > 0 ? (
|
||||
<>
|
||||
{(shopBrands[shop.id]?.length || 0)} brand{(shopBrands[shop.id]?.length || 0) !== 1 ? 's' : ''}
|
||||
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(shop.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
@ -264,6 +389,32 @@ const ShopList: React.FC = () => {
|
||||
message={`Are you sure you want to delete "${deletingShop?.name}"? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
|
||||
{/* Brands Popup */}
|
||||
{showBrandsPopup && hoveredShop && (shopBrands[hoveredShop.id]?.length || 0) > 0 && (
|
||||
<div
|
||||
className="brands-popup fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-sm"
|
||||
style={{
|
||||
left: `${popupPosition.x}px`,
|
||||
top: `${popupPosition.y}px`,
|
||||
maxHeight: '200px',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
onMouseEnter={() => setShowBrandsPopup(true)}
|
||||
onMouseLeave={handleBrandsLeave}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium text-gray-900 mb-2">Available Brands:</h4>
|
||||
{shopBrands[hoveredShop.id]?.map((brandInShop, index) => (
|
||||
<div key={index} className="border-b border-gray-100 pb-1 last:border-b-0">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{brandInShop.brand.name}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate } from '../types';
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate, Brand, BrandCreate, Grocery, GroceryCreate, GroceryCategory, GroceryCategoryCreate, BrandInShop, BrandInShopCreate } from '../types';
|
||||
|
||||
// Use different API URLs based on environment
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
@ -54,6 +54,16 @@ export const brandApi = {
|
||||
delete: (id: number) => api.delete(`/brands/${id}`),
|
||||
};
|
||||
|
||||
// BrandInShop API functions
|
||||
export const brandInShopApi = {
|
||||
getAll: () => api.get<BrandInShop[]>('/brands-in-shops/'),
|
||||
getByShop: (shopId: number) => api.get<BrandInShop[]>(`/brands-in-shops/shop/${shopId}`),
|
||||
getByBrand: (brandId: number) => api.get<BrandInShop[]>(`/brands-in-shops/brand/${brandId}`),
|
||||
getById: (id: number) => api.get<BrandInShop>(`/brands-in-shops/${id}`),
|
||||
create: (brandInShop: BrandInShopCreate) => api.post<BrandInShop>('/brands-in-shops/', brandInShop),
|
||||
delete: (id: number) => api.delete(`/brands-in-shops/${id}`),
|
||||
};
|
||||
|
||||
// Grocery Category API functions
|
||||
export const groceryCategoryApi = {
|
||||
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
|
||||
|
||||
@ -122,4 +122,19 @@ export interface ShopStats {
|
||||
total_spent: number;
|
||||
visit_count: number;
|
||||
avg_per_visit: number;
|
||||
}
|
||||
|
||||
export interface BrandInShop {
|
||||
id: number;
|
||||
shop_id: number;
|
||||
brand_id: number;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
shop: Shop;
|
||||
brand: Brand;
|
||||
}
|
||||
|
||||
export interface BrandInShopCreate {
|
||||
shop_id: number;
|
||||
brand_id: number;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user