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:
2025-05-25 18:51:47 +02:00
parent 500cb8983c
commit 2fadb2d991
13 changed files with 863 additions and 122 deletions

View File

@@ -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])

View File

@@ -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

View File

@@ -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