Compare commits
3 Commits
e20d0f0524
...
2846bcbb1c
| Author | SHA1 | Date | |
|---|---|---|---|
| 2846bcbb1c | |||
| 7037be370e | |||
| 629a89524c |
@ -272,6 +272,75 @@ def delete_brand(brand_id: int, db: Session = Depends(get_db)):
|
|||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Brand deleted successfully"}
|
return {"message": "Brand deleted successfully"}
|
||||||
|
|
||||||
|
# BrandInShop endpoints
|
||||||
|
@app.post("/brands-in-shops/", response_model=schemas.BrandInShop)
|
||||||
|
def create_brand_in_shop(brand_in_shop: schemas.BrandInShopCreate, db: Session = Depends(get_db)):
|
||||||
|
# Validate shop exists
|
||||||
|
shop = db.query(models.Shop).filter(models.Shop.id == brand_in_shop.shop_id).first()
|
||||||
|
if shop is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
# Validate brand exists
|
||||||
|
brand = db.query(models.Brand).filter(models.Brand.id == brand_in_shop.brand_id).first()
|
||||||
|
if brand is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Brand not found")
|
||||||
|
|
||||||
|
# Check if this combination already exists
|
||||||
|
existing = db.query(models.BrandInShop).filter(
|
||||||
|
models.BrandInShop.shop_id == brand_in_shop.shop_id,
|
||||||
|
models.BrandInShop.brand_id == brand_in_shop.brand_id
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(status_code=400, detail="This brand is already associated with this shop")
|
||||||
|
|
||||||
|
db_brand_in_shop = models.BrandInShop(**brand_in_shop.dict())
|
||||||
|
db.add(db_brand_in_shop)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(db_brand_in_shop)
|
||||||
|
return db_brand_in_shop
|
||||||
|
|
||||||
|
@app.get("/brands-in-shops/", response_model=List[schemas.BrandInShop])
|
||||||
|
def read_brands_in_shops(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||||
|
brands_in_shops = db.query(models.BrandInShop).offset(skip).limit(limit).all()
|
||||||
|
return brands_in_shops
|
||||||
|
|
||||||
|
@app.get("/brands-in-shops/shop/{shop_id}", response_model=List[schemas.BrandInShop])
|
||||||
|
def read_brands_in_shop(shop_id: int, db: Session = Depends(get_db)):
|
||||||
|
# Validate shop exists
|
||||||
|
shop = db.query(models.Shop).filter(models.Shop.id == shop_id).first()
|
||||||
|
if shop is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Shop not found")
|
||||||
|
|
||||||
|
brands_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.shop_id == shop_id).all()
|
||||||
|
return brands_in_shop
|
||||||
|
|
||||||
|
@app.get("/brands-in-shops/brand/{brand_id}", response_model=List[schemas.BrandInShop])
|
||||||
|
def read_shops_with_brand(brand_id: int, db: Session = Depends(get_db)):
|
||||||
|
# Validate brand exists
|
||||||
|
brand = db.query(models.Brand).filter(models.Brand.id == brand_id).first()
|
||||||
|
if brand is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Brand not found")
|
||||||
|
|
||||||
|
shops_with_brand = db.query(models.BrandInShop).filter(models.BrandInShop.brand_id == brand_id).all()
|
||||||
|
return shops_with_brand
|
||||||
|
|
||||||
|
@app.get("/brands-in-shops/{brand_in_shop_id}", response_model=schemas.BrandInShop)
|
||||||
|
def read_brand_in_shop(brand_in_shop_id: int, db: Session = Depends(get_db)):
|
||||||
|
brand_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.id == brand_in_shop_id).first()
|
||||||
|
if brand_in_shop is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Brand in shop association not found")
|
||||||
|
return brand_in_shop
|
||||||
|
|
||||||
|
@app.delete("/brands-in-shops/{brand_in_shop_id}")
|
||||||
|
def delete_brand_in_shop(brand_in_shop_id: int, db: Session = Depends(get_db)):
|
||||||
|
brand_in_shop = db.query(models.BrandInShop).filter(models.BrandInShop.id == brand_in_shop_id).first()
|
||||||
|
if brand_in_shop is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Brand in shop association not found")
|
||||||
|
|
||||||
|
db.delete(brand_in_shop)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Brand in shop association deleted successfully"}
|
||||||
|
|
||||||
# Grocery Category endpoints
|
# Grocery Category endpoints
|
||||||
@app.post("/grocery-categories/", response_model=schemas.GroceryCategory)
|
@app.post("/grocery-categories/", response_model=schemas.GroceryCategory)
|
||||||
def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)):
|
def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)):
|
||||||
|
|||||||
@ -17,6 +17,19 @@ shopping_event_products = Table(
|
|||||||
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
|
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class BrandInShop(Base):
|
||||||
|
__tablename__ = "brands_in_shops"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
|
||||||
|
brand_id = Column(Integer, ForeignKey("brands.id"), nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
shop = relationship("Shop", back_populates="brands_in_shop")
|
||||||
|
brand = relationship("Brand", back_populates="shops_with_brand")
|
||||||
|
|
||||||
class Brand(Base):
|
class Brand(Base):
|
||||||
__tablename__ = "brands"
|
__tablename__ = "brands"
|
||||||
|
|
||||||
@ -27,6 +40,7 @@ class Brand(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
products = relationship("Product", back_populates="brand")
|
products = relationship("Product", back_populates="brand")
|
||||||
|
shops_with_brand = relationship("BrandInShop", back_populates="brand")
|
||||||
|
|
||||||
class GroceryCategory(Base):
|
class GroceryCategory(Base):
|
||||||
__tablename__ = "grocery_categories"
|
__tablename__ = "grocery_categories"
|
||||||
@ -82,6 +96,7 @@ class Shop(Base):
|
|||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
shopping_events = relationship("ShoppingEvent", back_populates="shop")
|
shopping_events = relationship("ShoppingEvent", back_populates="shop")
|
||||||
|
brands_in_shop = relationship("BrandInShop", back_populates="shop")
|
||||||
|
|
||||||
class ShoppingEvent(Base):
|
class ShoppingEvent(Base):
|
||||||
__tablename__ = "shopping_events"
|
__tablename__ = "shopping_events"
|
||||||
|
|||||||
@ -20,6 +20,28 @@ class Brand(BrandBase):
|
|||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|
||||||
|
# BrandInShop schemas
|
||||||
|
class BrandInShopBase(BaseModel):
|
||||||
|
shop_id: int
|
||||||
|
brand_id: int
|
||||||
|
|
||||||
|
class BrandInShopCreate(BrandInShopBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BrandInShopUpdate(BaseModel):
|
||||||
|
shop_id: Optional[int] = None
|
||||||
|
brand_id: Optional[int] = None
|
||||||
|
|
||||||
|
class BrandInShop(BrandInShopBase):
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: Optional[datetime] = None
|
||||||
|
shop: "Shop"
|
||||||
|
brand: "Brand"
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
# Grocery Category schemas
|
# Grocery Category schemas
|
||||||
class GroceryCategoryBase(BaseModel):
|
class GroceryCategoryBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
@ -169,3 +191,6 @@ class ShopStats(BaseModel):
|
|||||||
total_spent: float
|
total_spent: float
|
||||||
visit_count: int
|
visit_count: int
|
||||||
avg_per_visit: float
|
avg_per_visit: float
|
||||||
|
|
||||||
|
# Update forward references
|
||||||
|
BrandInShop.model_rebuild()
|
||||||
@ -1,6 +1,6 @@
|
|||||||
<mxfile host="65bd71144e">
|
<mxfile host="65bd71144e">
|
||||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||||
<mxGraphModel dx="1848" dy="501" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
<mxGraphModel dx="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>
|
<root>
|
||||||
<mxCell id="0"/>
|
<mxCell id="0"/>
|
||||||
<mxCell id="1" parent="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">
|
<mxCell id="128" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="129" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="128" vertex="1">
|
<mxCell id="129" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="128" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
@ -71,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">
|
<mxCell id="9" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="10" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
<mxCell id="10" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="9" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
@ -390,7 +390,7 @@
|
|||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="114" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
<mxCell id="114" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||||
<mxGeometry x="90" y="480" width="180" height="150" as="geometry"/>
|
<mxGeometry x="-430" y="414" width="180" height="150" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="115" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="114" vertex="1">
|
<mxCell id="115" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="114" vertex="1">
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
@ -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">
|
<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"/>
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
|
<mxCell id="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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
@ -526,68 +526,150 @@
|
|||||||
<Array as="points"/>
|
<Array as="points"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="148" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" vertex="1" parent="1">
|
<mxCell id="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"/>
|
<mxGeometry x="-210" y="715" width="180" height="150" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="149" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" vertex="1" parent="148">
|
<mxCell id="149" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="148" vertex="1">
|
||||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="149">
|
<mxCell id="151" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="149" vertex="1">
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="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"/>
|
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" vertex="1" parent="152">
|
<mxCell id="153" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="152" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" vertex="1" parent="148">
|
<mxCell id="155" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="148" vertex="1">
|
||||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="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">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" vertex="1" parent="155">
|
<mxCell id="157" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="155" vertex="1">
|
||||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="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"/>
|
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" vertex="1" parent="158">
|
<mxCell id="159" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="158" vertex="1">
|
||||||
<mxGeometry width="30" height="30" as="geometry">
|
<mxGeometry width="30" height="30" as="geometry">
|
||||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="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">
|
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</mxCell>
|
||||||
<mxCell id="161" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" edge="1" parent="1" source="149" target="138">
|
<mxCell id="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">
|
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||||
<mxPoint x="270" y="785" as="sourcePoint"/>
|
<mxPoint x="270" y="785" as="sourcePoint"/>
|
||||||
<mxPoint x="80" y="835" as="targetPoint"/>
|
<mxPoint x="80" y="835" as="targetPoint"/>
|
||||||
<Array as="points"/>
|
<Array as="points"/>
|
||||||
</mxGeometry>
|
</mxGeometry>
|
||||||
</mxCell>
|
</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>
|
</root>
|
||||||
</mxGraphModel>
|
</mxGraphModel>
|
||||||
</diagram>
|
</diagram>
|
||||||
|
|||||||
@ -4,7 +4,6 @@ import Dashboard from './components/Dashboard';
|
|||||||
import ShopList from './components/ShopList';
|
import ShopList from './components/ShopList';
|
||||||
import ProductList from './components/ProductList';
|
import ProductList from './components/ProductList';
|
||||||
import ShoppingEventList from './components/ShoppingEventList';
|
import ShoppingEventList from './components/ShoppingEventList';
|
||||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
|
||||||
import BrandList from './components/BrandList';
|
import BrandList from './components/BrandList';
|
||||||
import GroceryList from './components/GroceryList';
|
import GroceryList from './components/GroceryList';
|
||||||
import GroceryCategoryList from './components/GroceryCategoryList';
|
import GroceryCategoryList from './components/GroceryCategoryList';
|
||||||
@ -108,8 +107,6 @@ function App() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||||
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
|
|
||||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
|
||||||
<Route path="/shops" element={<ShopList />} />
|
<Route path="/shops" element={<ShopList />} />
|
||||||
<Route path="/products" element={<ProductList />} />
|
<Route path="/products" element={<ProductList />} />
|
||||||
<Route path="/brands" element={<BrandList />} />
|
<Route path="/brands" element={<BrandList />} />
|
||||||
|
|||||||
@ -36,6 +36,26 @@ const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandA
|
|||||||
setError('');
|
setError('');
|
||||||
}, [editBrand, isOpen]);
|
}, [editBrand, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (formData.name.trim()) {
|
||||||
|
handleSubmit(event as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, formData, loading, onClose]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim()) {
|
if (!formData.name.trim()) {
|
||||||
|
|||||||
@ -24,6 +24,24 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
|||||||
}
|
}
|
||||||
}, [category]);
|
}, [category]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (formData.name.trim()) {
|
||||||
|
handleSubmit(event as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [formData, loading, onClose]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -44,6 +62,7 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving category:', error);
|
console.error('Error saving category:', error);
|
||||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
|
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -58,10 +77,10 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
<div className={`mb-4 px-4 py-3 rounded ${
|
||||||
message.includes('Error')
|
message.includes('Error')
|
||||||
? 'bg-red-50 text-red-700'
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
: 'bg-green-50 text-green-700'
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -28,6 +28,24 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
|||||||
}
|
}
|
||||||
}, [grocery]);
|
}, [grocery]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (formData.name.trim() && formData.category_id > 0) {
|
||||||
|
handleSubmit(event as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [formData, loading, onClose]);
|
||||||
|
|
||||||
const fetchCategories = async () => {
|
const fetchCategories = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await groceryCategoryApi.getAll();
|
const response = await groceryCategoryApi.getAll();
|
||||||
@ -35,6 +53,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching categories:', error);
|
console.error('Error fetching categories:', error);
|
||||||
setMessage('Error loading categories. Please try again.');
|
setMessage('Error loading categories. Please try again.');
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -58,6 +77,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving grocery:', error);
|
console.error('Error saving grocery:', error);
|
||||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -72,10 +92,10 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
|||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
<div className={`mb-4 px-4 py-3 rounded ${
|
||||||
message.includes('Error')
|
message.includes('Error')
|
||||||
? 'bg-red-50 text-red-700'
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
: 'bg-green-50 text-green-700'
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -85,6 +85,26 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
|||||||
setError('');
|
setError('');
|
||||||
}, [editProduct, isOpen]);
|
}, [editProduct, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (formData.name.trim() && formData.grocery_id) {
|
||||||
|
handleSubmit(event as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, formData, loading, onClose]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim() || !formData.grocery_id) {
|
if (!formData.name.trim() || !formData.grocery_id) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { shopApi } from '../services/api';
|
import { shopApi, brandApi, brandInShopApi } from '../services/api';
|
||||||
import { Shop } from '../types';
|
import { Shop, Brand, BrandInShop } from '../types';
|
||||||
|
|
||||||
interface AddShopModalProps {
|
interface AddShopModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -13,37 +13,99 @@ interface ShopFormData {
|
|||||||
name: string;
|
name: string;
|
||||||
city: string;
|
city: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
selectedBrands: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdded, editShop }) => {
|
const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdded, editShop }) => {
|
||||||
const [formData, setFormData] = useState<ShopFormData>({
|
const [formData, setFormData] = useState<ShopFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
city: '',
|
city: '',
|
||||||
address: ''
|
address: '',
|
||||||
|
selectedBrands: []
|
||||||
});
|
});
|
||||||
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
const isEditMode = !!editShop;
|
const isEditMode = !!editShop;
|
||||||
|
|
||||||
|
// Load brands when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
fetchBrands();
|
||||||
|
if (editShop) {
|
||||||
|
loadShopBrands(editShop.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, editShop]);
|
||||||
|
|
||||||
|
const fetchBrands = async () => {
|
||||||
|
try {
|
||||||
|
const response = await brandApi.getAll();
|
||||||
|
setBrands(response.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching brands:', err);
|
||||||
|
setError('Failed to load brands. Please try again.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadShopBrands = async (shopId: number) => {
|
||||||
|
try {
|
||||||
|
const response = await brandInShopApi.getByShop(shopId);
|
||||||
|
const brandIds = response.data.map((brandInShop: BrandInShop) => brandInShop.brand_id);
|
||||||
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
selectedBrands: brandIds
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading shop brands:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize form data when editing
|
// Initialize form data when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editShop) {
|
if (editShop) {
|
||||||
setFormData({
|
setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
name: editShop.name,
|
name: editShop.name,
|
||||||
city: editShop.city,
|
city: editShop.city,
|
||||||
address: editShop.address || ''
|
address: editShop.address || ''
|
||||||
});
|
}));
|
||||||
} else {
|
} else {
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
city: '',
|
city: '',
|
||||||
address: ''
|
address: '',
|
||||||
|
selectedBrands: []
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setError('');
|
setError('');
|
||||||
}, [editShop, isOpen]);
|
}, [editShop, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||||
|
// Only trigger submit if not in a textarea and form is valid
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName !== 'TEXTAREA') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (formData.name.trim() && formData.city.trim()) {
|
||||||
|
handleSubmit(event as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, formData, loading, onClose]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!formData.name.trim() || !formData.city.trim()) {
|
if (!formData.name.trim() || !formData.city.trim()) {
|
||||||
@ -62,17 +124,48 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
|||||||
address: trimmedAddress && trimmedAddress.length > 0 ? trimmedAddress : null
|
address: trimmedAddress && trimmedAddress.length > 0 ? trimmedAddress : null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let shopId: number;
|
||||||
|
|
||||||
if (isEditMode && editShop) {
|
if (isEditMode && editShop) {
|
||||||
await shopApi.update(editShop.id, shopData);
|
const updatedShop = await shopApi.update(editShop.id, shopData);
|
||||||
|
shopId = editShop.id;
|
||||||
} else {
|
} else {
|
||||||
await shopApi.create(shopData);
|
const newShop = await shopApi.create(shopData);
|
||||||
|
shopId = newShop.data.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle brand associations
|
||||||
|
if (isEditMode && editShop) {
|
||||||
|
// Get existing brand associations
|
||||||
|
const existingBrands = await brandInShopApi.getByShop(editShop.id);
|
||||||
|
const existingBrandIds = existingBrands.data.map(b => b.brand_id);
|
||||||
|
|
||||||
|
// Remove brands that are no longer selected
|
||||||
|
for (const brandInShop of existingBrands.data) {
|
||||||
|
if (!formData.selectedBrands.includes(brandInShop.brand_id)) {
|
||||||
|
await brandInShopApi.delete(brandInShop.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new brand associations
|
||||||
|
for (const brandId of formData.selectedBrands) {
|
||||||
|
if (!existingBrandIds.includes(brandId)) {
|
||||||
|
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Create new brand associations for new shop
|
||||||
|
for (const brandId of formData.selectedBrands) {
|
||||||
|
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset form
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
city: '',
|
city: '',
|
||||||
address: ''
|
address: '',
|
||||||
|
selectedBrands: []
|
||||||
});
|
});
|
||||||
|
|
||||||
onShopAdded();
|
onShopAdded();
|
||||||
@ -93,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;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
<div 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="mt-3">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
@ -167,6 +269,34 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Available Brands (Optional)
|
||||||
|
</label>
|
||||||
|
<div className="max-h-40 overflow-y-auto border border-gray-300 rounded-md p-3 bg-gray-50">
|
||||||
|
{brands.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">Loading brands...</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{brands.map(brand => (
|
||||||
|
<label key={brand.id} className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={formData.selectedBrands.includes(brand.id)}
|
||||||
|
onChange={() => handleBrandToggle(brand.id)}
|
||||||
|
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-gray-900">{brand.name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">
|
||||||
|
Select the brands that are available in this shop
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end space-x-3 pt-4">
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@ -1,18 +1,27 @@
|
|||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent, BrandInShop } from '../types';
|
||||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
|
import { shopApi, productApi, shoppingEventApi, brandInShopApi } from '../services/api';
|
||||||
import { shopApi, productApi, shoppingEventApi } from '../services/api';
|
|
||||||
|
|
||||||
const ShoppingEventForm: React.FC = () => {
|
interface AddShoppingEventModalProps {
|
||||||
const { id } = useParams<{ id: string }>();
|
isOpen: boolean;
|
||||||
const navigate = useNavigate();
|
onClose: () => void;
|
||||||
|
onEventAdded: () => void;
|
||||||
|
editEvent?: ShoppingEvent | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onEventAdded,
|
||||||
|
editEvent
|
||||||
|
}) => {
|
||||||
const [shops, setShops] = useState<Shop[]>([]);
|
const [shops, setShops] = useState<Shop[]>([]);
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
const [shopBrands, setShopBrands] = useState<BrandInShop[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingEvent, setLoadingEvent] = useState(false);
|
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
|
|
||||||
const isEditMode = Boolean(id);
|
const isEditMode = Boolean(editEvent);
|
||||||
|
|
||||||
const [formData, setFormData] = useState<ShoppingEventCreate>({
|
const [formData, setFormData] = useState<ShoppingEventCreate>({
|
||||||
shop_id: 0,
|
shop_id: 0,
|
||||||
@ -36,21 +45,17 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchShoppingEvent = useCallback(async (eventId: number) => {
|
const loadEventData = useCallback(() => {
|
||||||
try {
|
if (editEvent) {
|
||||||
setLoadingEvent(true);
|
|
||||||
const response = await shoppingEventApi.getById(eventId);
|
|
||||||
const event = response.data;
|
|
||||||
|
|
||||||
// Use the date directly if it's already in YYYY-MM-DD format, otherwise format it
|
// Use the date directly if it's already in YYYY-MM-DD format, otherwise format it
|
||||||
let formattedDate = event.date;
|
let formattedDate = editEvent.date;
|
||||||
if (event.date.includes('T') || event.date.length > 10) {
|
if (editEvent.date.includes('T') || editEvent.date.length > 10) {
|
||||||
// If the date includes time or is longer than YYYY-MM-DD, extract just the date part
|
// If the date includes time or is longer than YYYY-MM-DD, extract just the date part
|
||||||
formattedDate = event.date.split('T')[0];
|
formattedDate = editEvent.date.split('T')[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map products to the format we need
|
// Map products to the format we need
|
||||||
const mappedProducts = event.products.map(p => ({
|
const mappedProducts = editEvent.products.map(p => ({
|
||||||
product_id: p.id,
|
product_id: p.id,
|
||||||
amount: p.amount,
|
amount: p.amount,
|
||||||
price: p.price
|
price: p.price
|
||||||
@ -60,34 +65,68 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
const calculatedTotal = calculateTotal(mappedProducts);
|
const calculatedTotal = calculateTotal(mappedProducts);
|
||||||
|
|
||||||
// Check if existing total matches calculated total (with small tolerance for floating point)
|
// Check if existing total matches calculated total (with small tolerance for floating point)
|
||||||
const existingTotal = event.total_amount || 0;
|
const existingTotal = editEvent.total_amount || 0;
|
||||||
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
|
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
|
||||||
|
|
||||||
setFormData({
|
setFormData({
|
||||||
shop_id: event.shop.id,
|
shop_id: editEvent.shop.id,
|
||||||
date: formattedDate,
|
date: formattedDate,
|
||||||
total_amount: event.total_amount,
|
total_amount: editEvent.total_amount,
|
||||||
notes: event.notes || '',
|
notes: editEvent.notes || '',
|
||||||
products: []
|
products: []
|
||||||
});
|
});
|
||||||
|
|
||||||
setSelectedProducts(mappedProducts);
|
setSelectedProducts(mappedProducts);
|
||||||
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
||||||
} catch (error) {
|
} else {
|
||||||
console.error('Error fetching shopping event:', error);
|
// Reset form for adding new event
|
||||||
setMessage('Error loading shopping event. Please try again.');
|
setFormData({
|
||||||
} finally {
|
shop_id: 0,
|
||||||
setLoadingEvent(false);
|
date: new Date().toISOString().split('T')[0],
|
||||||
|
total_amount: undefined,
|
||||||
|
notes: '',
|
||||||
|
products: []
|
||||||
|
});
|
||||||
|
setSelectedProducts([]);
|
||||||
|
setAutoCalculate(true);
|
||||||
}
|
}
|
||||||
}, []);
|
setMessage('');
|
||||||
|
}, [editEvent]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchShops();
|
if (isOpen) {
|
||||||
fetchProducts();
|
fetchShops();
|
||||||
if (isEditMode && id) {
|
fetchProducts();
|
||||||
fetchShoppingEvent(parseInt(id));
|
loadEventData();
|
||||||
}
|
}
|
||||||
}, [id, isEditMode, fetchShoppingEvent]);
|
}, [isOpen, loadEventData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||||
|
// Only trigger submit if not in a textarea and form is valid
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName !== 'TEXTAREA') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (formData.shop_id > 0 && selectedProducts.length > 0) {
|
||||||
|
handleSubmit(event as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, formData, selectedProducts, loading, onClose]);
|
||||||
|
|
||||||
// Update total amount whenever selectedProducts changes
|
// Update total amount whenever selectedProducts changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -106,6 +145,8 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
setShops(response.data);
|
setShops(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching shops:', error);
|
console.error('Error fetching shops:', error);
|
||||||
|
setMessage('Error loading shops. Please try again.');
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -115,9 +156,35 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
setProducts(response.data);
|
setProducts(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching products:', error);
|
console.error('Error fetching products:', error);
|
||||||
|
setMessage('Error loading products. Please try again.');
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchShopBrands = async (shopId: number) => {
|
||||||
|
if (shopId === 0) {
|
||||||
|
setShopBrands([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await brandInShopApi.getByShop(shopId);
|
||||||
|
setShopBrands(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching shop brands:', error);
|
||||||
|
setShopBrands([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Effect to load shop brands when shop selection changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (formData.shop_id > 0) {
|
||||||
|
fetchShopBrands(formData.shop_id);
|
||||||
|
} else {
|
||||||
|
setShopBrands([]);
|
||||||
|
}
|
||||||
|
}, [formData.shop_id]);
|
||||||
|
|
||||||
const addProductToEvent = () => {
|
const addProductToEvent = () => {
|
||||||
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
||||||
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
||||||
@ -152,34 +219,22 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
products: selectedProducts
|
products: selectedProducts
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isEditMode) {
|
if (isEditMode && editEvent) {
|
||||||
// Update existing event
|
await shoppingEventApi.update(editEvent.id, eventData);
|
||||||
console.log('Updating event data:', eventData);
|
|
||||||
await shoppingEventApi.update(parseInt(id!), eventData);
|
|
||||||
setMessage('Shopping event updated successfully!');
|
setMessage('Shopping event updated successfully!');
|
||||||
|
|
||||||
// Navigate back to shopping events list after a short delay
|
|
||||||
setTimeout(() => {
|
|
||||||
navigate('/shopping-events');
|
|
||||||
}, 1500);
|
|
||||||
} else {
|
} else {
|
||||||
// Create new event
|
|
||||||
await shoppingEventApi.create(eventData);
|
await shoppingEventApi.create(eventData);
|
||||||
setMessage('Shopping event created successfully!');
|
setMessage('Shopping event created successfully!');
|
||||||
|
|
||||||
// Reset form for add mode
|
|
||||||
setFormData({
|
|
||||||
shop_id: 0,
|
|
||||||
date: new Date().toISOString().split('T')[0],
|
|
||||||
total_amount: undefined,
|
|
||||||
notes: '',
|
|
||||||
products: []
|
|
||||||
});
|
|
||||||
setSelectedProducts([]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
onEventAdded();
|
||||||
|
onClose();
|
||||||
|
}, 1500);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Full error object:', error);
|
console.error('Error saving shopping event:', error);
|
||||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
|
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -194,37 +249,48 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
return `${product.name}${organicEmoji} ${weightInfo}`;
|
return `${product.name}${organicEmoji} ${weightInfo}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loadingEvent) {
|
// Filter products based on selected shop's brands
|
||||||
return (
|
const getFilteredProducts = () => {
|
||||||
<div className="flex justify-center items-center h-64">
|
// If no shop is selected or shop has no brands, show all products
|
||||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
|
if (formData.shop_id === 0 || shopBrands.length === 0) {
|
||||||
</div>
|
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 (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="relative top-10 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="mt-3">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="flex justify-between items-center mb-4">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
{isEditMode ? 'Edit Shopping Event' : 'Add New Event'}
|
{isEditMode ? 'Edit Shopping Event' : 'Add New Shopping Event'}
|
||||||
</h3>
|
</h3>
|
||||||
{isEditMode && (
|
<button
|
||||||
<button
|
onClick={onClose}
|
||||||
onClick={() => navigate('/shopping-events')}
|
className="text-gray-400 hover:text-gray-600"
|
||||||
className="text-gray-500 hover:text-gray-700"
|
>
|
||||||
>
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
← Back to Shopping Events
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</button>
|
</svg>
|
||||||
)}
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
<div className={`mb-4 px-4 py-3 rounded ${
|
||||||
message.includes('Error')
|
message.includes('Error')
|
||||||
? 'bg-red-50 text-red-700'
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
: 'bg-green-50 text-green-700'
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
</div>
|
||||||
@ -281,12 +347,38 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
>
|
>
|
||||||
<option value={0}>Select a product</option>
|
<option value={0}>Select a product</option>
|
||||||
{products.map(product => (
|
{Object.entries(
|
||||||
<option key={product.id} value={product.id}>
|
getFilteredProducts().reduce((groups, product) => {
|
||||||
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category.name}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
const category = product.grocery.category.name;
|
||||||
</option>
|
if (!groups[category]) {
|
||||||
|
groups[category] = [];
|
||||||
|
}
|
||||||
|
groups[category].push(product);
|
||||||
|
return groups;
|
||||||
|
}, {} as Record<string, typeof products>)
|
||||||
|
)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([category, categoryProducts]) => (
|
||||||
|
<optgroup key={category} label={category}>
|
||||||
|
{categoryProducts
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map(product => (
|
||||||
|
<option key={product.id} value={product.id}>
|
||||||
|
{product.name}{product.organic ? '🌱' : ''}{product.brand ? ` (${product.brand.name})` : ''} {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||||
|
</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</optgroup>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
<div className="w-24">
|
<div className="w-24">
|
||||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
@ -329,7 +421,7 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
|
|
||||||
{/* Selected Products List */}
|
{/* Selected Products List */}
|
||||||
{selectedProducts.length > 0 && (
|
{selectedProducts.length > 0 && (
|
||||||
<div className="bg-gray-50 rounded-md p-4">
|
<div className="bg-gray-50 rounded-md p-4 max-h-40 overflow-y-auto">
|
||||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
||||||
{selectedProducts.map((item, index) => (
|
{selectedProducts.map((item, index) => (
|
||||||
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
||||||
@ -419,24 +511,18 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submit Button */}
|
{/* Submit Button */}
|
||||||
<div className="flex justify-end space-x-3">
|
<div className="flex justify-end space-x-3 pt-4">
|
||||||
{isEditMode && (
|
<button
|
||||||
<button
|
type="button"
|
||||||
type="button"
|
onClick={onClose}
|
||||||
onClick={() => navigate('/shopping-events')}
|
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
>
|
||||||
>
|
Cancel
|
||||||
Cancel
|
</button>
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
|
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
|
||||||
className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${
|
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"
|
||||||
isEditMode
|
|
||||||
? 'bg-blue-600 hover:bg-blue-700'
|
|
||||||
: 'w-full bg-blue-500 hover:bg-blue-700 font-bold py-2 px-4 focus:outline-none focus:shadow-outline'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||||
@ -451,4 +537,4 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ShoppingEventForm;
|
export default AddShoppingEventModal;
|
||||||
@ -14,6 +14,8 @@ const BrandList: React.FC = () => {
|
|||||||
const [editingBrand, setEditingBrand] = useState<Brand | null>(null);
|
const [editingBrand, setEditingBrand] = useState<Brand | null>(null);
|
||||||
const [deletingBrand, setDeletingBrand] = useState<Brand | null>(null);
|
const [deletingBrand, setDeletingBrand] = useState<Brand | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [sortField, setSortField] = useState<keyof Brand>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchBrands();
|
fetchBrands();
|
||||||
@ -82,6 +84,58 @@ const BrandList: React.FC = () => {
|
|||||||
setDeletingBrand(null);
|
setDeletingBrand(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: keyof Brand) => {
|
||||||
|
if (field === sortField) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedBrands = [...brands].sort((a, b) => {
|
||||||
|
let aValue = a[sortField];
|
||||||
|
let bValue = b[sortField];
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (aValue === null || aValue === undefined) aValue = '';
|
||||||
|
if (bValue === null || bValue === undefined) bValue = '';
|
||||||
|
|
||||||
|
// Convert to string for comparison
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSortIcon = (field: keyof Brand) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@ -118,47 +172,73 @@ const BrandList: React.FC = () => {
|
|||||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
|
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
{brands.map((brand) => (
|
<thead className="bg-gray-50">
|
||||||
<div key={brand.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
<tr>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<th
|
||||||
<h3 className="text-lg font-medium text-gray-900">{brand.name}</h3>
|
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"
|
||||||
<div className="flex space-x-2">
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Name
|
||||||
|
{getSortIcon('name')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Created
|
||||||
|
{getSortIcon('created_at')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('updated_at')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Updated
|
||||||
|
{getSortIcon('updated_at')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{sortedBrands.map((brand) => (
|
||||||
|
<tr key={brand.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{brand.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(brand.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{brand.updated_at ? new Date(brand.updated_at).toLocaleDateString() : '-'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditBrand(brand)}
|
onClick={() => handleEditBrand(brand)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteBrand(brand)}
|
onClick={() => handleDeleteBrand(brand)}
|
||||||
className="text-red-600 hover:text-red-900 text-sm"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
|
))}
|
||||||
<div className="space-y-2">
|
</tbody>
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
</table>
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
Added {new Date(brand.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{brand.updated_at && (
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Updated {new Date(brand.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
|
||||||
interface ConfirmDeleteModalProps {
|
interface ConfirmDeleteModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -17,6 +17,24 @@ const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({
|
|||||||
message,
|
message,
|
||||||
isLoading = false
|
isLoading = false
|
||||||
}) => {
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
} else if (event.key === 'Enter' && !isLoading) {
|
||||||
|
event.preventDefault();
|
||||||
|
onConfirm();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, isLoading, onClose, onConfirm]);
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -102,7 +102,7 @@ const Dashboard: React.FC = () => {
|
|||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/shopping-events/new')}
|
onClick={() => navigate('/shopping-events?add=true')}
|
||||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { GroceryCategory } from '../types';
|
import { GroceryCategory } from '../types';
|
||||||
import { groceryCategoryApi } from '../services/api';
|
import { groceryCategoryApi } from '../services/api';
|
||||||
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
||||||
|
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
|
||||||
const GroceryCategoryList: React.FC = () => {
|
const GroceryCategoryList: React.FC = () => {
|
||||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||||
@ -9,6 +10,10 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingCategory, setEditingCategory] = useState<GroceryCategory | null>(null);
|
const [editingCategory, setEditingCategory] = useState<GroceryCategory | null>(null);
|
||||||
|
const [deletingCategory, setDeletingCategory] = useState<GroceryCategory | null>(null);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [sortField, setSortField] = useState<keyof GroceryCategory>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
@ -27,25 +32,37 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (category: GroceryCategory) => {
|
||||||
if (window.confirm('Are you sure you want to delete this category?')) {
|
setDeletingCategory(category);
|
||||||
try {
|
};
|
||||||
await groceryCategoryApi.delete(id);
|
|
||||||
setMessage('Category deleted successfully!');
|
const confirmDelete = async () => {
|
||||||
fetchCategories();
|
if (!deletingCategory) return;
|
||||||
setTimeout(() => setMessage(''), 3000);
|
|
||||||
} catch (error: any) {
|
try {
|
||||||
console.error('Error deleting category:', error);
|
setDeleteLoading(true);
|
||||||
if (error.response?.status === 400) {
|
await groceryCategoryApi.delete(deletingCategory.id);
|
||||||
setMessage('Cannot delete category: groceries are still associated with this category.');
|
setMessage('Category deleted successfully!');
|
||||||
} else {
|
setDeletingCategory(null);
|
||||||
setMessage('Error deleting category. Please try again.');
|
fetchCategories();
|
||||||
}
|
setTimeout(() => setMessage(''), 1500);
|
||||||
setTimeout(() => setMessage(''), 5000);
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting category:', error);
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
setMessage('Cannot delete category: groceries are still associated with this category.');
|
||||||
|
} else {
|
||||||
|
setMessage('Error deleting category. Please try again.');
|
||||||
}
|
}
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setDeletingCategory(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (category: GroceryCategory) => {
|
const handleEdit = (category: GroceryCategory) => {
|
||||||
setEditingCategory(category);
|
setEditingCategory(category);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@ -57,6 +74,58 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
fetchCategories();
|
fetchCategories();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: keyof GroceryCategory) => {
|
||||||
|
if (field === sortField) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedCategories = [...categories].sort((a, b) => {
|
||||||
|
let aValue = a[sortField];
|
||||||
|
let bValue = b[sortField];
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (aValue === null || aValue === undefined) aValue = '';
|
||||||
|
if (bValue === null || bValue === undefined) bValue = '';
|
||||||
|
|
||||||
|
// Convert to string for comparison
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSortIcon = (field: keyof GroceryCategory) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@ -66,81 +135,93 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="space-y-6">
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="flex justify-between items-center">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<h1 className="text-2xl font-bold text-gray-900">Grocery Categories</h1>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<button
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
onClick={() => setIsModalOpen(true)}
|
||||||
Grocery Categories
|
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
</h3>
|
>
|
||||||
<button
|
Add New Category
|
||||||
onClick={() => setIsModalOpen(true)}
|
</button>
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
</div>
|
||||||
>
|
|
||||||
Add Category
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
<div className={`px-4 py-3 rounded ${
|
||||||
message.includes('Error') || message.includes('Cannot')
|
message.includes('Error') || message.includes('Cannot')
|
||||||
? 'bg-red-50 text-red-700'
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
: 'bg-green-50 text-green-700'
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{categories.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-gray-500">No categories found. Add your first category!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<tr key={category.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{category.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{new Date(category.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(category)}
|
|
||||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(category.id)}
|
|
||||||
className="text-red-600 hover:text-red-900"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||||
|
{categories.length === 0 ? (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a1.994 1.994 0 01-1.414.586H7m0-18v18m0-18h.01" />
|
||||||
|
</svg>
|
||||||
|
<h3 className="mt-2 text-sm font-medium text-gray-900">No categories</h3>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">Get started by adding your first category.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Name
|
||||||
|
{getSortIcon('name')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Created
|
||||||
|
{getSortIcon('created_at')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{sortedCategories.map((category) => (
|
||||||
|
<tr key={category.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{category.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(category.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(category)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(category)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
@ -149,6 +230,15 @@ const GroceryCategoryList: React.FC = () => {
|
|||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<ConfirmDeleteModal
|
||||||
|
isOpen={!!deletingCategory}
|
||||||
|
onClose={handleCloseDeleteModal}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
title="Delete Category"
|
||||||
|
message={`Are you sure you want to delete "${deletingCategory?.name}"? This action cannot be undone.`}
|
||||||
|
isLoading={deleteLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import { Grocery } from '../types';
|
import { Grocery } from '../types';
|
||||||
import { groceryApi } from '../services/api';
|
import { groceryApi } from '../services/api';
|
||||||
import AddGroceryModal from './AddGroceryModal';
|
import AddGroceryModal from './AddGroceryModal';
|
||||||
|
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
|
||||||
const GroceryList: React.FC = () => {
|
const GroceryList: React.FC = () => {
|
||||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||||
@ -9,6 +10,10 @@ const GroceryList: React.FC = () => {
|
|||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
||||||
|
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [sortField, setSortField] = useState<string>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchGroceries();
|
fetchGroceries();
|
||||||
@ -27,25 +32,37 @@ const GroceryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (grocery: Grocery) => {
|
||||||
if (window.confirm('Are you sure you want to delete this grocery?')) {
|
setDeletingGrocery(grocery);
|
||||||
try {
|
};
|
||||||
await groceryApi.delete(id);
|
|
||||||
setMessage('Grocery deleted successfully!');
|
const confirmDelete = async () => {
|
||||||
fetchGroceries();
|
if (!deletingGrocery) return;
|
||||||
setTimeout(() => setMessage(''), 3000);
|
|
||||||
} catch (error: any) {
|
try {
|
||||||
console.error('Error deleting grocery:', error);
|
setDeleteLoading(true);
|
||||||
if (error.response?.status === 400) {
|
await groceryApi.delete(deletingGrocery.id);
|
||||||
setMessage('Cannot delete grocery: products are still associated with this grocery.');
|
setMessage('Grocery deleted successfully!');
|
||||||
} else {
|
setDeletingGrocery(null);
|
||||||
setMessage('Error deleting grocery. Please try again.');
|
fetchGroceries();
|
||||||
}
|
setTimeout(() => setMessage(''), 1500);
|
||||||
setTimeout(() => setMessage(''), 5000);
|
} catch (error: any) {
|
||||||
|
console.error('Error deleting grocery:', error);
|
||||||
|
if (error.response?.status === 400) {
|
||||||
|
setMessage('Cannot delete grocery: products are still associated with this grocery.');
|
||||||
|
} else {
|
||||||
|
setMessage('Error deleting grocery. Please try again.');
|
||||||
}
|
}
|
||||||
|
setTimeout(() => setMessage(''), 3000);
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setDeletingGrocery(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleEdit = (grocery: Grocery) => {
|
const handleEdit = (grocery: Grocery) => {
|
||||||
setEditingGrocery(grocery);
|
setEditingGrocery(grocery);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@ -57,6 +74,76 @@ const GroceryList: React.FC = () => {
|
|||||||
fetchGroceries();
|
fetchGroceries();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
if (field === sortField) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedGroceries = [...groceries].sort((a, b) => {
|
||||||
|
let aValue: any;
|
||||||
|
let bValue: any;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name':
|
||||||
|
aValue = a.name;
|
||||||
|
bValue = b.name;
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
aValue = a.category.name;
|
||||||
|
bValue = b.category.name;
|
||||||
|
break;
|
||||||
|
case 'created_at':
|
||||||
|
aValue = a.created_at;
|
||||||
|
bValue = b.created_at;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = '';
|
||||||
|
bValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (aValue === null || aValue === undefined) aValue = '';
|
||||||
|
if (bValue === null || bValue === undefined) bValue = '';
|
||||||
|
|
||||||
|
// Convert to string for comparison
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSortIcon = (field: string) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@ -66,87 +153,105 @@ const GroceryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="space-y-6">
|
||||||
<div className="bg-white shadow rounded-lg">
|
<div className="flex justify-between items-center">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||||
<div className="flex justify-between items-center mb-4">
|
<button
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
onClick={() => setIsModalOpen(true)}
|
||||||
Groceries
|
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
</h3>
|
>
|
||||||
<button
|
Add New Grocery
|
||||||
onClick={() => setIsModalOpen(true)}
|
</button>
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
</div>
|
||||||
>
|
|
||||||
Add Grocery
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
<div className={`mb-4 p-4 rounded-md ${
|
<div className={`px-4 py-3 rounded ${
|
||||||
message.includes('Error') || message.includes('Cannot')
|
message.includes('Error') || message.includes('Cannot')
|
||||||
? 'bg-red-50 text-red-700'
|
? 'bg-red-50 border border-red-200 text-red-700'
|
||||||
: 'bg-green-50 text-green-700'
|
: 'bg-green-50 border border-green-200 text-green-700'
|
||||||
}`}>
|
}`}>
|
||||||
{message}
|
{message}
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{groceries.length === 0 ? (
|
|
||||||
<div className="text-center py-8">
|
|
||||||
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
|
||||||
<table className="min-w-full divide-y divide-gray-300">
|
|
||||||
<thead className="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Category
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Created
|
|
||||||
</th>
|
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
Actions
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
|
||||||
{groceries.map((grocery) => (
|
|
||||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
|
||||||
{grocery.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{grocery.category.name}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{new Date(grocery.created_at).toLocaleDateString()}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
|
||||||
<button
|
|
||||||
onClick={() => handleEdit(grocery)}
|
|
||||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(grocery.id)}
|
|
||||||
className="text-red-600 hover:text-red-900"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead className="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Name
|
||||||
|
{getSortIcon('name')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('category')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Category
|
||||||
|
{getSortIcon('category')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Created
|
||||||
|
{getSortIcon('created_at')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{sortedGroceries.map((grocery) => (
|
||||||
|
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{grocery.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{grocery.category.name}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(grocery.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
|
<button
|
||||||
|
onClick={() => handleEdit(grocery)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(grocery)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isModalOpen && (
|
{isModalOpen && (
|
||||||
@ -155,6 +260,15 @@ const GroceryList: React.FC = () => {
|
|||||||
onClose={handleModalClose}
|
onClose={handleModalClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -14,6 +14,8 @@ const ProductList: React.FC = () => {
|
|||||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||||
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
|
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [sortField, setSortField] = useState<string>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
@ -77,6 +79,92 @@ const ProductList: React.FC = () => {
|
|||||||
setDeletingProduct(null);
|
setDeletingProduct(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
if (field === sortField) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedProducts = [...products].sort((a, b) => {
|
||||||
|
let aValue: any;
|
||||||
|
let bValue: any;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case 'name':
|
||||||
|
aValue = a.name;
|
||||||
|
bValue = b.name;
|
||||||
|
break;
|
||||||
|
case 'grocery':
|
||||||
|
aValue = a.grocery.name;
|
||||||
|
bValue = b.grocery.name;
|
||||||
|
break;
|
||||||
|
case 'category':
|
||||||
|
aValue = a.grocery.category.name;
|
||||||
|
bValue = b.grocery.category.name;
|
||||||
|
break;
|
||||||
|
case 'brand':
|
||||||
|
aValue = a.brand?.name || '';
|
||||||
|
bValue = b.brand?.name || '';
|
||||||
|
break;
|
||||||
|
case 'weight':
|
||||||
|
aValue = a.weight || 0;
|
||||||
|
bValue = b.weight || 0;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = '';
|
||||||
|
bValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (aValue === null || aValue === undefined) aValue = '';
|
||||||
|
if (bValue === null || bValue === undefined) bValue = '';
|
||||||
|
|
||||||
|
// Convert to string for comparison (except for numbers)
|
||||||
|
if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue - bValue;
|
||||||
|
} else {
|
||||||
|
return bValue - aValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSortIcon = (field: string) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@ -119,20 +207,50 @@ const ProductList: React.FC = () => {
|
|||||||
<table className="min-w-full divide-y divide-gray-200">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
<thead className="bg-gray-50">
|
<thead className="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
Name
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Name
|
||||||
|
{getSortIcon('name')}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
Grocery
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('grocery')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Grocery
|
||||||
|
{getSortIcon('grocery')}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
Brand
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('category')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Category
|
||||||
|
{getSortIcon('category')}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
Weight
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('brand')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Brand
|
||||||
|
{getSortIcon('brand')}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th
|
||||||
Organic
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('weight')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Weight
|
||||||
|
{getSortIcon('weight')}
|
||||||
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Actions
|
Actions
|
||||||
@ -140,7 +258,7 @@ const ProductList: React.FC = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="bg-white divide-y divide-gray-200">
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
{products.map((product) => (
|
{sortedProducts.map((product) => (
|
||||||
<tr key={product.id} className="hover:bg-gray-50">
|
<tr key={product.id} className="hover:bg-gray-50">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm font-medium text-gray-900">
|
<div className="text-sm font-medium text-gray-900">
|
||||||
@ -149,7 +267,9 @@ const ProductList: React.FC = () => {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className="text-sm text-gray-900">{product.grocery.name}</div>
|
<div className="text-sm text-gray-900">{product.grocery.name}</div>
|
||||||
<div className="text-xs text-gray-500">{product.grocery.category.name}</div>
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{product.grocery.category.name}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
{product.brand ? product.brand.name : '-'}
|
{product.brand ? product.brand.name : '-'}
|
||||||
@ -157,15 +277,6 @@ const ProductList: React.FC = () => {
|
|||||||
<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.weight ? `${product.weight}${product.weight_unit}` : '-'}
|
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
|
||||||
product.organic
|
|
||||||
? 'bg-green-100 text-green-800'
|
|
||||||
: 'bg-gray-100 text-gray-800'
|
|
||||||
}`}>
|
|
||||||
{product.organic ? 'Organic' : 'Conventional'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(product)}
|
onClick={() => handleEdit(product)}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Shop } from '../types';
|
import { Shop, BrandInShop } from '../types';
|
||||||
import { shopApi } from '../services/api';
|
import { shopApi, brandInShopApi } from '../services/api';
|
||||||
import AddShopModal from './AddShopModal';
|
import AddShopModal from './AddShopModal';
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
|
||||||
@ -14,6 +14,12 @@ const ShopList: React.FC = () => {
|
|||||||
const [editingShop, setEditingShop] = useState<Shop | null>(null);
|
const [editingShop, setEditingShop] = useState<Shop | null>(null);
|
||||||
const [deletingShop, setDeletingShop] = useState<Shop | null>(null);
|
const [deletingShop, setDeletingShop] = useState<Shop | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [sortField, setSortField] = useState<keyof Shop>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
|
const [hoveredShop, setHoveredShop] = useState<Shop | null>(null);
|
||||||
|
const [showBrandsPopup, setShowBrandsPopup] = useState(false);
|
||||||
|
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [shopBrands, setShopBrands] = useState<Record<number, BrandInShop[]>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchShops();
|
fetchShops();
|
||||||
@ -26,11 +32,35 @@ const ShopList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [searchParams, setSearchParams]);
|
}, [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 () => {
|
const fetchShops = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const response = await shopApi.getAll();
|
const response = await shopApi.getAll();
|
||||||
setShops(response.data);
|
setShops(response.data);
|
||||||
|
|
||||||
|
// Load brands for all shops
|
||||||
|
for (const shop of response.data) {
|
||||||
|
loadShopBrands(shop.id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to fetch shops');
|
setError('Failed to fetch shops');
|
||||||
console.error('Error fetching shops:', err);
|
console.error('Error fetching shops:', err);
|
||||||
@ -39,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 = () => {
|
const handleShopAdded = () => {
|
||||||
fetchShops(); // Refresh the shops list
|
fetchShops(); // Refresh the shops list
|
||||||
};
|
};
|
||||||
@ -77,6 +119,118 @@ const ShopList: React.FC = () => {
|
|||||||
setDeletingShop(null);
|
setDeletingShop(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBrandsHover = (shop: Shop, mouseEvent: React.MouseEvent) => {
|
||||||
|
const brands = shopBrands[shop.id] || [];
|
||||||
|
if (brands.length === 0) return;
|
||||||
|
|
||||||
|
const 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');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedShops = [...shops].sort((a, b) => {
|
||||||
|
let aValue = a[sortField];
|
||||||
|
let bValue = b[sortField];
|
||||||
|
|
||||||
|
// Handle null/undefined values
|
||||||
|
if (aValue === null || aValue === undefined) aValue = '';
|
||||||
|
if (bValue === null || bValue === undefined) bValue = '';
|
||||||
|
|
||||||
|
// Convert to string for comparison
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSortIcon = (field: keyof Shop) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@ -113,64 +267,110 @@ const ShopList: React.FC = () => {
|
|||||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first shop.</p>
|
<p className="mt-1 text-sm text-gray-500">Get started by adding your first shop.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
{shops.map((shop) => (
|
<thead className="bg-gray-50">
|
||||||
<div key={shop.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
<tr>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<th
|
||||||
<h3 className="text-lg font-medium text-gray-900">{shop.name}</h3>
|
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"
|
||||||
<div className="flex space-x-2">
|
onClick={() => handleSort('name')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Name
|
||||||
|
{getSortIcon('name')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('city')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
City
|
||||||
|
{getSortIcon('city')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('address')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Address
|
||||||
|
{getSortIcon('address')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Brands
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('created_at')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Created
|
||||||
|
{getSortIcon('created_at')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{sortedShops.map((shop) => (
|
||||||
|
<tr key={shop.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{shop.name}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{shop.city}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{shop.address || '-'}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`brands-cell px-6 py-4 whitespace-nowrap text-sm ${
|
||||||
|
(shopBrands[shop.id]?.length || 0) > 0
|
||||||
|
? 'text-blue-600 hover:text-blue-800 cursor-pointer hover:bg-blue-50'
|
||||||
|
: 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={(e) => handleBrandsHover(shop, e)}
|
||||||
|
onMouseLeave={handleBrandsLeave}
|
||||||
|
onClick={(e) => handleBrandsClick(shop, e)}
|
||||||
|
title={(shopBrands[shop.id]?.length || 0) > 0 ? 'Click to view brands' : ''}
|
||||||
|
>
|
||||||
|
{(shopBrands[shop.id]?.length || 0) > 0 ? (
|
||||||
|
<>
|
||||||
|
{(shopBrands[shop.id]?.length || 0)} brand{(shopBrands[shop.id]?.length || 0) !== 1 ? 's' : ''}
|
||||||
|
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{new Date(shop.created_at).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEditShop(shop)}
|
onClick={() => handleEditShop(shop)}
|
||||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteShop(shop)}
|
onClick={() => handleDeleteShop(shop)}
|
||||||
className="text-red-600 hover:text-red-900 text-sm"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
|
))}
|
||||||
<div className="space-y-2">
|
</tbody>
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
</table>
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
||||||
</svg>
|
|
||||||
{shop.city}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shop.address && (
|
|
||||||
<div className="flex items-start text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
{shop.address}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
|
||||||
</svg>
|
|
||||||
Added {new Date(shop.created_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{shop.updated_at && (
|
|
||||||
<div className="flex items-center text-sm text-gray-600">
|
|
||||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Updated {new Date(shop.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -189,6 +389,32 @@ const ShopList: React.FC = () => {
|
|||||||
message={`Are you sure you want to delete "${deletingShop?.name}"? This action cannot be undone.`}
|
message={`Are you sure you want to delete "${deletingShop?.name}"? This action cannot be undone.`}
|
||||||
isLoading={deleteLoading}
|
isLoading={deleteLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Brands Popup */}
|
||||||
|
{showBrandsPopup && hoveredShop && (shopBrands[hoveredShop.id]?.length || 0) > 0 && (
|
||||||
|
<div
|
||||||
|
className="brands-popup fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-sm"
|
||||||
|
style={{
|
||||||
|
left: `${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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,20 +1,54 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { ShoppingEvent } from '../types';
|
import { ShoppingEvent } from '../types';
|
||||||
import { shoppingEventApi } from '../services/api';
|
import { shoppingEventApi } from '../services/api';
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
import AddShoppingEventModal from './AddShoppingEventModal';
|
||||||
|
|
||||||
const ShoppingEventList: React.FC = () => {
|
const ShoppingEventList: React.FC = () => {
|
||||||
const navigate = useNavigate();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [events, setEvents] = useState<ShoppingEvent[]>([]);
|
const [events, setEvents] = useState<ShoppingEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [deletingEvent, setDeletingEvent] = useState<ShoppingEvent | null>(null);
|
const [deletingEvent, setDeletingEvent] = useState<ShoppingEvent | null>(null);
|
||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [editingEvent, setEditingEvent] = useState<ShoppingEvent | null>(null);
|
||||||
|
const [hoveredEvent, setHoveredEvent] = useState<ShoppingEvent | null>(null);
|
||||||
|
const [showItemsPopup, setShowItemsPopup] = useState(false);
|
||||||
|
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
|
||||||
|
const [sortField, setSortField] = useState<string>('date');
|
||||||
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
fetchEvents();
|
||||||
}, []);
|
|
||||||
|
// Check if we should auto-open the modal
|
||||||
|
if (searchParams.get('add') === 'true') {
|
||||||
|
setIsModalOpen(true);
|
||||||
|
// Remove the parameter from URL
|
||||||
|
setSearchParams({});
|
||||||
|
}
|
||||||
|
}, [searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
// Handle clicking outside popup to close it
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (showItemsPopup && !target.closest('.items-popup') && !target.closest('.items-cell')) {
|
||||||
|
setShowItemsPopup(false);
|
||||||
|
setHoveredEvent(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showItemsPopup) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [showItemsPopup]);
|
||||||
|
|
||||||
const fetchEvents = async () => {
|
const fetchEvents = async () => {
|
||||||
try {
|
try {
|
||||||
@ -53,6 +87,167 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
setDeletingEvent(null);
|
setDeletingEvent(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleEdit = (event: ShoppingEvent) => {
|
||||||
|
setEditingEvent(event);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEventAdded = () => {
|
||||||
|
fetchEvents(); // Refresh the events list
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemsHover = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
|
||||||
|
if (event.products.length === 0) return;
|
||||||
|
|
||||||
|
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||||
|
const popupWidth = 384; // max-w-md is approximately 384px
|
||||||
|
const popupHeight = 300; // max height we set
|
||||||
|
|
||||||
|
let x = mouseEvent.clientX + 10;
|
||||||
|
let y = mouseEvent.clientY - 10;
|
||||||
|
|
||||||
|
// Adjust if popup would go off screen
|
||||||
|
if (x + popupWidth > window.innerWidth) {
|
||||||
|
x = mouseEvent.clientX - popupWidth - 10;
|
||||||
|
}
|
||||||
|
if (y + popupHeight > window.innerHeight) {
|
||||||
|
y = mouseEvent.clientY - popupHeight + 10;
|
||||||
|
}
|
||||||
|
if (y < 0) {
|
||||||
|
y = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHoveredEvent(event);
|
||||||
|
setPopupPosition({ x, y });
|
||||||
|
setShowItemsPopup(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemsLeave = () => {
|
||||||
|
setShowItemsPopup(false);
|
||||||
|
setHoveredEvent(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleItemsClick = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
|
||||||
|
if (event.products.length === 0) return;
|
||||||
|
|
||||||
|
mouseEvent.stopPropagation();
|
||||||
|
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||||
|
const popupWidth = 384; // max-w-md is approximately 384px
|
||||||
|
const popupHeight = 300; // max height we set
|
||||||
|
|
||||||
|
let x = mouseEvent.clientX + 10;
|
||||||
|
let y = mouseEvent.clientY - 10;
|
||||||
|
|
||||||
|
// Adjust if popup would go off screen
|
||||||
|
if (x + popupWidth > window.innerWidth) {
|
||||||
|
x = mouseEvent.clientX - popupWidth - 10;
|
||||||
|
}
|
||||||
|
if (y + popupHeight > window.innerHeight) {
|
||||||
|
y = mouseEvent.clientY - popupHeight + 10;
|
||||||
|
}
|
||||||
|
if (y < 0) {
|
||||||
|
y = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
setHoveredEvent(event);
|
||||||
|
setPopupPosition({ x, y });
|
||||||
|
setShowItemsPopup(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSort = (field: string) => {
|
||||||
|
if (field === sortField) {
|
||||||
|
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedEvents = [...events].sort((a, b) => {
|
||||||
|
let aValue: any;
|
||||||
|
let bValue: any;
|
||||||
|
|
||||||
|
switch (sortField) {
|
||||||
|
case 'shop':
|
||||||
|
aValue = a.shop.name;
|
||||||
|
bValue = b.shop.name;
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
aValue = new Date(a.date);
|
||||||
|
bValue = new Date(b.date);
|
||||||
|
break;
|
||||||
|
case 'items':
|
||||||
|
aValue = a.products.length;
|
||||||
|
bValue = b.products.length;
|
||||||
|
break;
|
||||||
|
case 'total':
|
||||||
|
aValue = a.total_amount || 0;
|
||||||
|
bValue = b.total_amount || 0;
|
||||||
|
break;
|
||||||
|
case 'notes':
|
||||||
|
aValue = a.notes || '';
|
||||||
|
bValue = b.notes || '';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
aValue = '';
|
||||||
|
bValue = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle different data types
|
||||||
|
if (aValue instanceof Date && bValue instanceof Date) {
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue.getTime() - bValue.getTime();
|
||||||
|
} else {
|
||||||
|
return bValue.getTime() - aValue.getTime();
|
||||||
|
}
|
||||||
|
} else if (typeof aValue === 'number' && typeof bValue === 'number') {
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aValue - bValue;
|
||||||
|
} else {
|
||||||
|
return bValue - aValue;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// String comparison
|
||||||
|
const aStr = String(aValue).toLowerCase();
|
||||||
|
const bStr = String(bValue).toLowerCase();
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return aStr.localeCompare(bStr);
|
||||||
|
} else {
|
||||||
|
return bStr.localeCompare(aStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const getSortIcon = (field: string) => {
|
||||||
|
if (sortField !== field) {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortDirection === 'asc') {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<svg className="w-4 h-4 ml-1 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@ -66,7 +261,7 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate('/shopping-events/new')}
|
onClick={() => setIsModalOpen(true)}
|
||||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
>
|
>
|
||||||
Add New Event
|
Add New Event
|
||||||
@ -89,67 +284,109 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
<p className="mt-1 text-sm text-gray-500">Get started by recording your first purchase.</p>
|
<p className="mt-1 text-sm text-gray-500">Get started by recording your first purchase.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-4 p-6">
|
<table className="min-w-full divide-y divide-gray-200">
|
||||||
{events.map((event) => (
|
<thead className="bg-gray-50">
|
||||||
<div key={event.id} className="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
<tr>
|
||||||
<div className="flex justify-between items-start mb-4">
|
<th
|
||||||
<div>
|
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"
|
||||||
<h3 className="text-lg font-medium text-gray-900">{event.shop.name}</h3>
|
onClick={() => handleSort('shop')}
|
||||||
<p className="text-sm text-gray-600">{event.shop.city}</p>
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Shop
|
||||||
|
{getSortIcon('shop')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
</th>
|
||||||
<p className="text-sm font-medium text-gray-900">
|
<th
|
||||||
{new Date(event.date).toLocaleDateString()}
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
</p>
|
onClick={() => handleSort('date')}
|
||||||
{event.total_amount && (
|
>
|
||||||
<p className="text-lg font-semibold text-green-600">
|
<div className="flex items-center">
|
||||||
|
Date
|
||||||
|
{getSortIcon('date')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('items')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Items
|
||||||
|
{getSortIcon('items')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('total')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Total
|
||||||
|
{getSortIcon('total')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
|
||||||
|
onClick={() => handleSort('notes')}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
Notes
|
||||||
|
{getSortIcon('notes')}
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white divide-y divide-gray-200">
|
||||||
|
{sortedEvents.map((event) => (
|
||||||
|
<tr key={event.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="text-sm font-medium text-gray-900">{event.shop.name}</div>
|
||||||
|
<div className="text-xs text-gray-500">{event.shop.city}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||||
|
{new Date(event.date).toLocaleDateString()}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
className={`items-cell px-6 py-4 whitespace-nowrap text-sm ${
|
||||||
|
event.products.length > 0
|
||||||
|
? 'text-blue-600 hover:text-blue-800 cursor-pointer hover:bg-blue-50'
|
||||||
|
: 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
onMouseEnter={(e) => handleItemsHover(event, e)}
|
||||||
|
onMouseLeave={handleItemsLeave}
|
||||||
|
onClick={(e) => handleItemsClick(event, e)}
|
||||||
|
title={event.products.length > 0 ? 'Click to view items' : ''}
|
||||||
|
>
|
||||||
|
{event.products.length} item{event.products.length !== 1 ? 's' : ''}
|
||||||
|
{event.products.length > 0 && (
|
||||||
|
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
{event.total_amount ? (
|
||||||
|
<span className="text-sm font-semibold text-green-600">
|
||||||
${event.total_amount.toFixed(2)}
|
${event.total_amount.toFixed(2)}
|
||||||
</p>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-500">-</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</td>
|
||||||
</div>
|
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{event.notes ? (
|
||||||
{event.products.length > 0 && (
|
<span className="truncate max-w-xs block" title={event.notes}>
|
||||||
<div className="mb-4">
|
{event.notes.length > 30 ? `${event.notes.substring(0, 30)}...` : event.notes}
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
|
</span>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
) : (
|
||||||
{event.products.map((product) => (
|
'-'
|
||||||
<div key={product.id} className="bg-gray-50 rounded px-3 py-2">
|
|
||||||
<div className="text-sm text-gray-900">
|
|
||||||
{product.name} {product.organic ? '🌱' : ''}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-600">
|
|
||||||
{product.amount} × ${product.price.toFixed(2)} = ${(product.amount * product.price).toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{event.notes && (
|
|
||||||
<div className="mb-4">
|
|
||||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Notes:</h4>
|
|
||||||
<p className="text-sm text-gray-600">{event.notes}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center text-sm">
|
|
||||||
<div className="text-gray-500">
|
|
||||||
<div>Event #{event.id} • Created {new Date(event.created_at).toLocaleDateString()}</div>
|
|
||||||
{event.updated_at && (
|
|
||||||
<div className="flex items-center mt-1">
|
|
||||||
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
||||||
</svg>
|
|
||||||
Updated {new Date(event.updated_at).toLocaleDateString()}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</td>
|
||||||
<div className="flex space-x-2">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}
|
onClick={() => handleEdit(event)}
|
||||||
className="text-indigo-600 hover:text-indigo-900"
|
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@ -159,14 +396,21 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</td>
|
||||||
</div>
|
</tr>
|
||||||
</div>
|
))}
|
||||||
))}
|
</tbody>
|
||||||
</div>
|
</table>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<AddShoppingEventModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={handleCloseModal}
|
||||||
|
onEventAdded={handleEventAdded}
|
||||||
|
editEvent={editingEvent}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDeleteModal
|
<ConfirmDeleteModal
|
||||||
isOpen={!!deletingEvent}
|
isOpen={!!deletingEvent}
|
||||||
onClose={handleCloseDeleteModal}
|
onClose={handleCloseDeleteModal}
|
||||||
@ -175,6 +419,54 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
message={`Are you sure you want to delete this shopping event from ${deletingEvent?.shop.name}? This action cannot be undone.`}
|
message={`Are you sure you want to delete this shopping event from ${deletingEvent?.shop.name}? This action cannot be undone.`}
|
||||||
isLoading={deleteLoading}
|
isLoading={deleteLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Items Popup */}
|
||||||
|
{showItemsPopup && hoveredEvent && hoveredEvent.products.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="items-popup fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-md"
|
||||||
|
style={{
|
||||||
|
left: `${popupPosition.x + 10}px`,
|
||||||
|
top: `${popupPosition.y - 10}px`,
|
||||||
|
maxHeight: '300px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}
|
||||||
|
onMouseEnter={() => setShowItemsPopup(true)}
|
||||||
|
onMouseLeave={handleItemsLeave}
|
||||||
|
>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{hoveredEvent.products.map((product, index) => (
|
||||||
|
<div key={index} className="border-b border-gray-100 pb-2 last:border-b-0">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
{product.name} {product.organic ? '🌱' : ''}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600">
|
||||||
|
{product.grocery?.category?.name || 'Unknown category'}
|
||||||
|
</div>
|
||||||
|
{product.brand && (
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Brand: {product.brand.name}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right ml-2">
|
||||||
|
<div className="text-sm font-medium text-gray-900">
|
||||||
|
${product.price.toFixed(2)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500">
|
||||||
|
Qty: {product.amount}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-green-600">
|
||||||
|
${(product.amount * product.price).toFixed(2)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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
|
// Use different API URLs based on environment
|
||||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||||
@ -54,6 +54,16 @@ export const brandApi = {
|
|||||||
delete: (id: number) => api.delete(`/brands/${id}`),
|
delete: (id: number) => api.delete(`/brands/${id}`),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// BrandInShop API functions
|
||||||
|
export const brandInShopApi = {
|
||||||
|
getAll: () => api.get<BrandInShop[]>('/brands-in-shops/'),
|
||||||
|
getByShop: (shopId: number) => api.get<BrandInShop[]>(`/brands-in-shops/shop/${shopId}`),
|
||||||
|
getByBrand: (brandId: number) => api.get<BrandInShop[]>(`/brands-in-shops/brand/${brandId}`),
|
||||||
|
getById: (id: number) => api.get<BrandInShop>(`/brands-in-shops/${id}`),
|
||||||
|
create: (brandInShop: BrandInShopCreate) => api.post<BrandInShop>('/brands-in-shops/', brandInShop),
|
||||||
|
delete: (id: number) => api.delete(`/brands-in-shops/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
// Grocery Category API functions
|
// Grocery Category API functions
|
||||||
export const groceryCategoryApi = {
|
export const groceryCategoryApi = {
|
||||||
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
|
getAll: () => api.get<GroceryCategory[]>('/grocery-categories/'),
|
||||||
|
|||||||
@ -123,3 +123,18 @@ export interface ShopStats {
|
|||||||
visit_count: number;
|
visit_count: number;
|
||||||
avg_per_visit: number;
|
avg_per_visit: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BrandInShop {
|
||||||
|
id: number;
|
||||||
|
shop_id: number;
|
||||||
|
brand_id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at?: string;
|
||||||
|
shop: Shop;
|
||||||
|
brand: Brand;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrandInShopCreate {
|
||||||
|
shop_id: number;
|
||||||
|
brand_id: number;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user