feat: Implement comprehensive edit functionality and standardize UI components
• Add full edit functionality for groceries with modal support
• Standardize delete confirmations across all components using ConfirmDeleteModal
• Implement complete shopping event edit functionality:
- Create EditShoppingEvent component with full form capabilities
- Add missing backend PUT endpoint for shopping events
- Support editing all event data (shop, date, groceries, amounts, prices, notes)
• Add inline grocery edit functionality in shopping event forms:
- Allow editing grocery items within add/edit forms
- Load selected items back into input fields for modification
• Fix date timezone issues in edit forms to prevent date shifting
• Remove non-functional "View Details" button in favor of working Edit button
• Enhance user experience with consistent edit/delete patterns across the app
Breaking changes: None
Backend: Added PUT /shopping-events/{id} and DELETE /shopping-events/{id} endpoints
Frontend: Complete edit workflow for all entities with improved UX
This commit is contained in:
113
backend/main.py
113
backend/main.py
@@ -1,6 +1,7 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import text
|
||||
from typing import List
|
||||
import models, schemas
|
||||
from database import engine, get_db
|
||||
@@ -23,6 +24,46 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
||||
"""Build a shopping event response with groceries from the association table"""
|
||||
# Get groceries with their event-specific data
|
||||
grocery_data = db.execute(
|
||||
text("""
|
||||
SELECT g.id, g.name, g.category, g.organic, g.weight, g.weight_unit,
|
||||
seg.amount, seg.price
|
||||
FROM groceries g
|
||||
JOIN shopping_event_groceries seg ON g.id = seg.grocery_id
|
||||
WHERE seg.shopping_event_id = :event_id
|
||||
"""),
|
||||
{"event_id": event.id}
|
||||
).fetchall()
|
||||
|
||||
# Convert to GroceryWithEventData objects
|
||||
groceries_with_data = [
|
||||
schemas.GroceryWithEventData(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
category=row.category,
|
||||
organic=row.organic,
|
||||
weight=row.weight,
|
||||
weight_unit=row.weight_unit,
|
||||
amount=row.amount,
|
||||
price=row.price
|
||||
)
|
||||
for row in grocery_data
|
||||
]
|
||||
|
||||
return schemas.ShoppingEventResponse(
|
||||
id=event.id,
|
||||
shop_id=event.shop_id,
|
||||
date=event.date,
|
||||
total_amount=event.total_amount,
|
||||
notes=event.notes,
|
||||
created_at=event.created_at,
|
||||
shop=event.shop,
|
||||
groceries=groceries_with_data
|
||||
)
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
@@ -148,25 +189,89 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
|
||||
models.shopping_event_groceries.insert().values(
|
||||
shopping_event_id=db_event.id,
|
||||
grocery_id=grocery_item.grocery_id,
|
||||
amount=grocery_item.amount
|
||||
amount=grocery_item.amount,
|
||||
price=grocery_item.price
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_event)
|
||||
return db_event
|
||||
return build_shopping_event_response(db_event, db)
|
||||
|
||||
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
|
||||
def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
events = db.query(models.ShoppingEvent).offset(skip).limit(limit).all()
|
||||
return events
|
||||
return [build_shopping_event_response(event, db) for event in events]
|
||||
|
||||
@app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
|
||||
def read_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
||||
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||
return event
|
||||
return build_shopping_event_response(event, db)
|
||||
|
||||
@app.put("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
|
||||
def update_shopping_event(event_id: int, event_update: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
||||
# Get the existing event
|
||||
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||
|
||||
# Verify shop exists
|
||||
shop = db.query(models.Shop).filter(models.Shop.id == event_update.shop_id).first()
|
||||
if shop is None:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
|
||||
# Update the shopping event
|
||||
event.shop_id = event_update.shop_id
|
||||
event.date = event_update.date
|
||||
event.total_amount = event_update.total_amount
|
||||
event.notes = event_update.notes
|
||||
|
||||
# Remove existing grocery associations
|
||||
db.execute(
|
||||
models.shopping_event_groceries.delete().where(
|
||||
models.shopping_event_groceries.c.shopping_event_id == event_id
|
||||
)
|
||||
)
|
||||
|
||||
# Add new grocery associations
|
||||
for grocery_item in event_update.groceries:
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
|
||||
|
||||
# Insert into association table
|
||||
db.execute(
|
||||
models.shopping_event_groceries.insert().values(
|
||||
shopping_event_id=event_id,
|
||||
grocery_id=grocery_item.grocery_id,
|
||||
amount=grocery_item.amount,
|
||||
price=grocery_item.price
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(event)
|
||||
return build_shopping_event_response(event, db)
|
||||
|
||||
@app.delete("/shopping-events/{event_id}")
|
||||
def delete_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
||||
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||
|
||||
# Delete grocery associations first
|
||||
db.execute(
|
||||
models.shopping_event_groceries.delete().where(
|
||||
models.shopping_event_groceries.c.shopping_event_id == event_id
|
||||
)
|
||||
)
|
||||
|
||||
# Delete the shopping event
|
||||
db.delete(event)
|
||||
db.commit()
|
||||
return {"message": "Shopping event deleted successfully"}
|
||||
|
||||
# Statistics endpoints
|
||||
@app.get("/stats/categories", response_model=List[schemas.CategoryStats])
|
||||
|
||||
@@ -12,7 +12,8 @@ shopping_event_groceries = Table(
|
||||
Base.metadata,
|
||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), primary_key=True),
|
||||
Column('grocery_id', Integer, ForeignKey('groceries.id'), primary_key=True),
|
||||
Column('amount', Float, nullable=False) # Amount of this grocery bought in this event
|
||||
Column('amount', Float, nullable=False), # Amount of this grocery bought in this event
|
||||
Column('price', Float, nullable=False) # Price of this grocery at the time of this shopping event
|
||||
)
|
||||
|
||||
class Grocery(Base):
|
||||
@@ -20,7 +21,6 @@ class Grocery(Base):
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
price = Column(Float, nullable=False)
|
||||
category = Column(String, nullable=False)
|
||||
organic = Column(Boolean, default=False)
|
||||
weight = Column(Float, nullable=True) # in grams or kg
|
||||
|
||||
@@ -5,7 +5,6 @@ from datetime import datetime
|
||||
# Base schemas
|
||||
class GroceryBase(BaseModel):
|
||||
name: str
|
||||
price: float = Field(..., gt=0)
|
||||
category: str
|
||||
organic: bool = False
|
||||
weight: Optional[float] = None
|
||||
@@ -16,7 +15,6 @@ class GroceryCreate(GroceryBase):
|
||||
|
||||
class GroceryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
price: Optional[float] = Field(None, gt=0)
|
||||
category: Optional[str] = None
|
||||
organic: Optional[bool] = None
|
||||
weight: Optional[float] = None
|
||||
@@ -55,6 +53,20 @@ class Shop(ShopBase):
|
||||
class GroceryInEvent(BaseModel):
|
||||
grocery_id: int
|
||||
amount: float = Field(..., gt=0)
|
||||
price: float = Field(..., gt=0) # Price at the time of this shopping event
|
||||
|
||||
class GroceryWithEventData(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: str
|
||||
organic: bool
|
||||
weight: Optional[float] = None
|
||||
weight_unit: str
|
||||
amount: float # Amount purchased in this event
|
||||
price: float # Price at the time of this event
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
class ShoppingEventBase(BaseModel):
|
||||
shop_id: int
|
||||
@@ -76,7 +88,7 @@ class ShoppingEventResponse(ShoppingEventBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
shop: Shop
|
||||
groceries: List[Grocery] = []
|
||||
groceries: List[GroceryWithEventData] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
Reference in New Issue
Block a user