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:
lasse 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

View File

@ -4,6 +4,7 @@ import GroceryList from './components/GroceryList';
import ShopList from './components/ShopList';
import ShoppingEventForm from './components/ShoppingEventForm';
import ShoppingEventList from './components/ShoppingEventList';
import EditShoppingEvent from './components/EditShoppingEvent';
import Dashboard from './components/Dashboard';
function App() {
@ -49,7 +50,7 @@ function App() {
to="/add-purchase"
className="bg-blue-500 hover:bg-blue-700 text-white inline-flex items-center px-3 py-2 text-sm font-medium rounded-md"
>
Add Purchase
Add New Event
</Link>
</div>
</div>
@ -64,6 +65,7 @@ function App() {
<Route path="/groceries" element={<GroceryList />} />
<Route path="/shops" element={<ShopList />} />
<Route path="/shopping-events" element={<ShoppingEventList />} />
<Route path="/shopping-events/:id/edit" element={<EditShoppingEvent />} />
<Route path="/add-purchase" element={<ShoppingEventForm />} />
</Routes>
</main>

View File

@ -1,25 +1,25 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { groceryApi } from '../services/api';
import { Grocery } from '../types';
interface AddGroceryModalProps {
isOpen: boolean;
onClose: () => void;
onGroceryAdded: () => void;
editGrocery?: Grocery | null;
}
interface GroceryFormData {
name: string;
price: number;
category: string;
organic: boolean;
weight?: number;
weight_unit: string;
}
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded }) => {
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => {
const [formData, setFormData] = useState<GroceryFormData>({
name: '',
price: 0,
category: '',
organic: false,
weight: undefined,
@ -35,9 +35,32 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
// Populate form when editing
useEffect(() => {
if (editGrocery) {
setFormData({
name: editGrocery.name,
category: editGrocery.category,
organic: editGrocery.organic,
weight: editGrocery.weight,
weight_unit: editGrocery.weight_unit
});
} else {
// Reset form for adding new grocery
setFormData({
name: '',
category: '',
organic: false,
weight: undefined,
weight_unit: 'piece'
});
}
setError('');
}, [editGrocery, isOpen]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.category.trim() || formData.price <= 0) {
if (!formData.name.trim() || !formData.category.trim()) {
setError('Please fill in all required fields with valid values');
return;
}
@ -51,12 +74,17 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
weight: formData.weight || undefined
};
await groceryApi.create(groceryData);
if (editGrocery) {
// Update existing grocery
await groceryApi.update(editGrocery.id, groceryData);
} else {
// Create new grocery
await groceryApi.create(groceryData);
}
// Reset form
setFormData({
name: '',
price: 0,
category: '',
organic: false,
weight: undefined,
@ -66,8 +94,8 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
onGroceryAdded();
onClose();
} catch (err) {
setError('Failed to add grocery. Please try again.');
console.error('Error adding grocery:', err);
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`);
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err);
} finally {
setLoading(false);
}
@ -90,7 +118,9 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">Add New Grocery</h3>
<h3 className="text-lg font-medium text-gray-900">
{editGrocery ? 'Edit Grocery' : 'Add New Grocery'}
</h3>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
@ -124,24 +154,6 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
/>
</div>
<div>
<label htmlFor="price" className="block text-sm font-medium text-gray-700">
Price ($) *
</label>
<input
type="number"
id="price"
name="price"
value={formData.price || ''}
onChange={handleChange}
required
min="0"
step="0.01"
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="0.00"
/>
</div>
<div>
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
Category *
@ -223,7 +235,10 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
disabled={loading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Adding...' : 'Add Grocery'}
{loading
? (editGrocery ? 'Updating...' : 'Adding...')
: (editGrocery ? 'Update Grocery' : 'Add Grocery')
}
</button>
</div>
</form>

View File

@ -1,6 +1,33 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ShoppingEvent } from '../types';
import { shoppingEventApi } from '../services/api';
const Dashboard: React.FC = () => {
const navigate = useNavigate();
const [recentEvents, setRecentEvents] = useState<ShoppingEvent[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchRecentEvents();
}, []);
const fetchRecentEvents = async () => {
try {
setLoading(true);
const response = await shoppingEventApi.getAll();
// Get the 3 most recent events
const recent = response.data
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
.slice(0, 3);
setRecentEvents(recent);
} catch (error) {
console.error('Error fetching recent events:', error);
} finally {
setLoading(false);
}
};
return (
<div className="space-y-6">
<div>
@ -74,19 +101,25 @@ const Dashboard: React.FC = () => {
</div>
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<button
onClick={() => navigate('/add-purchase')}
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">
<svg className="w-5 h-5 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</div>
<div>
<p className="font-medium text-gray-900">Add Purchase</p>
<p className="font-medium text-gray-900">Add New Event</p>
<p className="text-sm text-gray-600">Record a new shopping event</p>
</div>
</button>
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<button
onClick={() => navigate('/groceries?add=true')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="p-2 bg-green-100 rounded-md mr-3">
<svg className="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
@ -98,7 +131,10 @@ const Dashboard: React.FC = () => {
</div>
</button>
<button className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
<button
onClick={() => navigate('/shops?add=true')}
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
<div className="p-2 bg-purple-100 rounded-md mr-3">
<svg className="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
@ -119,13 +155,56 @@ const Dashboard: React.FC = () => {
<h2 className="text-lg font-medium text-gray-900">Recent Shopping Events</h2>
</div>
<div className="p-6">
<div className="text-center py-8">
<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 shopping events yet</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first purchase!</p>
</div>
{loading ? (
<div className="flex justify-center items-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
</div>
) : recentEvents.length === 0 ? (
<div className="text-center py-8">
<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 shopping events yet</h3>
<p className="mt-1 text-sm text-gray-500">Get started by adding your first event!</p>
</div>
) : (
<div className="space-y-4">
{recentEvents.map((event) => (
<div key={event.id} className="border border-gray-200 rounded-lg p-4 hover:bg-gray-50 transition-colors">
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center space-x-2">
<h4 className="font-medium text-gray-900">{event.shop.name}</h4>
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500">{event.shop.city}</span>
</div>
<p className="text-sm text-gray-600 mt-1">
{new Date(event.date).toLocaleDateString()}
</p>
{event.groceries.length > 0 && (
<p className="text-sm text-gray-500 mt-1">
{event.groceries.length} item{event.groceries.length !== 1 ? 's' : ''}
</p>
)}
</div>
<div className="text-right">
{event.total_amount && (
<p className="font-semibold text-green-600">
${event.total_amount.toFixed(2)}
</p>
)}
<button
onClick={() => navigate('/shopping-events')}
className="text-sm text-blue-600 hover:text-blue-800 mt-1"
>
View all
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>

View File

@ -0,0 +1,367 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Shop, Grocery, ShoppingEvent, ShoppingEventCreate, GroceryInEvent } from '../types';
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
const EditShoppingEvent: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [shops, setShops] = useState<Shop[]>([]);
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(false);
const [loadingEvent, setLoadingEvent] = useState(true);
const [message, setMessage] = useState('');
const [formData, setFormData] = useState<ShoppingEventCreate>({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
});
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
grocery_id: 0,
amount: 1,
price: 0
});
useEffect(() => {
fetchShops();
fetchGroceries();
if (id) {
fetchShoppingEvent(parseInt(id));
}
}, [id]);
const fetchShoppingEvent = async (eventId: number) => {
try {
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
let formattedDate = event.date;
if (event.date.includes('T') || event.date.length > 10) {
// If the date includes time or is longer than YYYY-MM-DD, extract just the date part
formattedDate = event.date.split('T')[0];
}
setFormData({
shop_id: event.shop.id,
date: formattedDate,
total_amount: event.total_amount,
notes: event.notes || '',
groceries: []
});
setSelectedGroceries(event.groceries.map(g => ({
grocery_id: g.id,
amount: g.amount,
price: g.price
})));
} catch (error) {
console.error('Error fetching shopping event:', error);
setMessage('Error loading shopping event. Please try again.');
} finally {
setLoadingEvent(false);
}
};
const fetchShops = async () => {
try {
const response = await shopApi.getAll();
setShops(response.data);
} catch (error) {
console.error('Error fetching shops:', error);
}
};
const fetchGroceries = async () => {
try {
const response = await groceryApi.getAll();
setGroceries(response.data);
} catch (error) {
console.error('Error fetching groceries:', error);
}
};
const addGroceryToEvent = () => {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price > 0) {
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
setNewGroceryItem({ grocery_id: 0, amount: 1, price: 0 });
}
};
const removeGroceryFromEvent = (index: number) => {
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
};
const editGroceryFromEvent = (index: number) => {
const groceryToEdit = selectedGroceries[index];
// Load the grocery data into the input fields
setNewGroceryItem({
grocery_id: groceryToEdit.grocery_id,
amount: groceryToEdit.amount,
price: groceryToEdit.price
});
// Remove the item from the selected list
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setMessage('');
try {
const eventData = {
...formData,
groceries: selectedGroceries
};
console.log('Sending event data:', eventData);
const response = await shoppingEventApi.update(parseInt(id!), eventData);
console.log('Response:', response);
setMessage('Shopping event updated successfully!');
// Navigate back to shopping events list after a short delay
setTimeout(() => {
navigate('/shopping-events');
}, 1500);
} catch (error) {
console.error('Full error object:', error);
setMessage('Error updating shopping event. Please try again.');
console.error('Error:', error);
} finally {
setLoading(false);
}
};
const getGroceryName = (id: number) => {
const grocery = groceries.find(g => g.id === id);
return grocery ? grocery.name : 'Unknown';
};
if (loadingEvent) {
return (
<div className="flex justify-center items-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"></div>
</div>
);
}
return (
<div className="max-w-4xl mx-auto">
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
Edit Shopping Event
</h3>
<button
onClick={() => navigate('/shopping-events')}
className="text-gray-500 hover:text-gray-700"
>
Back to Shopping Events
</button>
</div>
{message && (
<div className={`mb-4 p-4 rounded-md ${
message.includes('Error')
? 'bg-red-50 text-red-700'
: 'bg-green-50 text-green-700'
}`}>
{message}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-6">
{/* Shop Selection */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Shop
</label>
<select
value={formData.shop_id}
onChange={(e) => setFormData({...formData, shop_id: parseInt(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
>
<option value={0}>Select a shop</option>
{shops.map(shop => (
<option key={shop.id} value={shop.id}>
{shop.name} - {shop.city}
</option>
))}
</select>
</div>
{/* Date */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Date
</label>
<input
type="date"
value={formData.date}
onChange={(e) => setFormData({...formData, date: e.target.value})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required
/>
</div>
{/* Add Groceries Section */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Add Groceries
</label>
<div className="flex space-x-2 mb-4">
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Grocery
</label>
<select
value={newGroceryItem.grocery_id}
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
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 grocery</option>
{groceries.map(grocery => (
<option key={grocery.id} value={grocery.id}>
{grocery.name} ({grocery.category})
</option>
))}
</select>
</div>
<div className="w-24">
<label className="block text-xs font-medium text-gray-700 mb-1">
Amount
</label>
<input
type="number"
step="1"
min="1"
placeholder="1"
value={newGroceryItem.amount}
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="w-24">
<label className="block text-xs font-medium text-gray-700 mb-1">
Price ($)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={newGroceryItem.price}
onChange={(e) => setNewGroceryItem({...newGroceryItem, price: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<button
type="button"
onClick={addGroceryToEvent}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add
</button>
</div>
</div>
</div>
{/* Selected Groceries */}
{selectedGroceries.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Selected Groceries
</label>
<div className="space-y-2">
{selectedGroceries.map((item, index) => (
<div key={index} className="flex items-center justify-between bg-gray-50 p-3 rounded">
<span>
{getGroceryName(item.grocery_id)} - {item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
</span>
<div className="flex space-x-2">
<button
type="button"
onClick={() => editGroceryFromEvent(index)}
className="text-blue-600 hover:text-blue-800"
>
Edit
</button>
<button
type="button"
onClick={() => removeGroceryFromEvent(index)}
className="text-red-600 hover:text-red-800"
>
Remove
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Total Amount */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Amount (optional)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="Leave empty to auto-calculate"
value={formData.total_amount || ''}
onChange={(e) => setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes (optional)
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: e.target.value})}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Any additional notes about this shopping event..."
/>
</div>
{/* Submit Button */}
<div className="flex justify-end space-x-3">
<button
type="button"
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"
>
Cancel
</button>
<button
type="submit"
disabled={loading || formData.shop_id === 0}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Updating...' : 'Update Shopping Event'}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditShoppingEvent;

View File

@ -1,17 +1,30 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Grocery } from '../types';
import { groceryApi } from '../services/api';
import AddGroceryModal from './AddGroceryModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const GroceryList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [groceries, setGroceries] = useState<Grocery[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
useEffect(() => {
fetchGroceries();
}, []);
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const fetchGroceries = async () => {
try {
@ -26,15 +39,28 @@ const GroceryList: React.FC = () => {
}
};
const handleDelete = async (id: number) => {
if (window.confirm('Are you sure you want to delete this grocery item?')) {
try {
await groceryApi.delete(id);
setGroceries(groceries.filter(g => g.id !== id));
} catch (err) {
setError('Failed to delete grocery');
console.error('Error deleting grocery:', err);
}
const handleEdit = (grocery: Grocery) => {
setEditingGrocery(grocery);
setIsModalOpen(true);
};
const handleDelete = (grocery: Grocery) => {
setDeletingGrocery(grocery);
};
const confirmDelete = async () => {
if (!deletingGrocery) return;
try {
setDeleteLoading(true);
await groceryApi.delete(deletingGrocery.id);
setDeletingGrocery(null);
fetchGroceries(); // Refresh the list
} catch (err) {
console.error('Error deleting grocery:', err);
setError('Failed to delete grocery. Please try again.');
} finally {
setDeleteLoading(false);
}
};
@ -42,6 +68,15 @@ const GroceryList: React.FC = () => {
fetchGroceries(); // Refresh the list
};
const handleCloseModal = () => {
setIsModalOpen(false);
setEditingGrocery(null);
};
const handleCloseDeleteModal = () => {
setDeletingGrocery(null);
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
@ -55,7 +90,10 @@ const GroceryList: React.FC = () => {
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
<button
onClick={() => setIsModalOpen(true)}
onClick={() => {
setEditingGrocery(null);
setIsModalOpen(true);
}}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add New Grocery
@ -84,9 +122,6 @@ const GroceryList: React.FC = () => {
<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">
Price
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Category
</th>
@ -107,9 +142,6 @@ const GroceryList: React.FC = () => {
<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">
<div className="text-sm text-gray-900">${grocery.price.toFixed(2)}</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
{grocery.category}
@ -128,11 +160,14 @@ const GroceryList: React.FC = () => {
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button className="text-indigo-600 hover:text-indigo-900 mr-3">
<button
onClick={() => handleEdit(grocery)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDelete(grocery.id)}
onClick={() => handleDelete(grocery)}
className="text-red-600 hover:text-red-900"
>
Delete
@ -147,8 +182,18 @@ const GroceryList: React.FC = () => {
<AddGroceryModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onClose={handleCloseModal}
onGroceryAdded={handleGroceryAdded}
editGrocery={editingGrocery}
/>
<ConfirmDeleteModal
isOpen={!!deletingGrocery}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Grocery"
message={`Are you sure you want to delete "${deletingGrocery?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);

View File

@ -1,10 +1,12 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Shop } from '../types';
import { shopApi } from '../services/api';
import AddShopModal from './AddShopModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const ShopList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const [shops, setShops] = useState<Shop[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@ -15,7 +17,14 @@ const ShopList: React.FC = () => {
useEffect(() => {
fetchShops();
}, []);
// Check if we should auto-open the modal
if (searchParams.get('add') === 'true') {
setIsModalOpen(true);
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
const fetchShops = async () => {
try {

View File

@ -19,7 +19,8 @@ const ShoppingEventForm: React.FC = () => {
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
grocery_id: 0,
amount: 1
amount: 1,
price: 0
});
useEffect(() => {
@ -46,9 +47,9 @@ const ShoppingEventForm: React.FC = () => {
};
const addGroceryToEvent = () => {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0) {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price > 0) {
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
setNewGroceryItem({ grocery_id: 0, amount: 1 });
setNewGroceryItem({ grocery_id: 0, amount: 1, price: 0 });
}
};
@ -56,6 +57,18 @@ const ShoppingEventForm: React.FC = () => {
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
};
const editGroceryFromEvent = (index: number) => {
const groceryToEdit = selectedGroceries[index];
// Load the grocery data into the input fields
setNewGroceryItem({
grocery_id: groceryToEdit.grocery_id,
amount: groceryToEdit.amount,
price: groceryToEdit.price
});
// Remove the item from the selected list
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
@ -97,7 +110,7 @@ const ShoppingEventForm: React.FC = () => {
<div className="bg-white shadow rounded-lg">
<div className="px-4 py-5 sm:p-6">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Add New Purchase
Add New Event
</h3>
{message && (
@ -151,34 +164,60 @@ const ShoppingEventForm: React.FC = () => {
Add Groceries
</label>
<div className="flex space-x-2 mb-4">
<select
value={newGroceryItem.grocery_id}
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
className="flex-1 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 grocery</option>
{groceries.map(grocery => (
<option key={grocery.id} value={grocery.id}>
{grocery.name} - ${grocery.price} ({grocery.category})
</option>
))}
</select>
<input
type="number"
step="0.1"
min="0.1"
placeholder="Amount"
value={newGroceryItem.amount}
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
className="w-24 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<button
type="button"
onClick={addGroceryToEvent}
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
>
Add
</button>
<div className="flex-1">
<label className="block text-xs font-medium text-gray-700 mb-1">
Grocery
</label>
<select
value={newGroceryItem.grocery_id}
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
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 grocery</option>
{groceries.map(grocery => (
<option key={grocery.id} value={grocery.id}>
{grocery.name} ({grocery.category})
</option>
))}
</select>
</div>
<div className="w-24">
<label className="block text-xs font-medium text-gray-700 mb-1">
Amount
</label>
<input
type="number"
step="1"
min="1"
placeholder="1"
value={newGroceryItem.amount}
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="w-24">
<label className="block text-xs font-medium text-gray-700 mb-1">
Price ($)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={newGroceryItem.price}
onChange={(e) => setNewGroceryItem({...newGroceryItem, price: parseFloat(e.target.value)})}
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-end">
<button
type="button"
onClick={addGroceryToEvent}
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
>
Add
</button>
</div>
</div>
{/* Selected Groceries List */}
@ -188,15 +227,24 @@ const ShoppingEventForm: React.FC = () => {
{selectedGroceries.map((item, index) => (
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
<span>
{getGroceryName(item.grocery_id)} x {item.amount}
{getGroceryName(item.grocery_id)} x {item.amount} @ ${item.price.toFixed(2)}
</span>
<button
type="button"
onClick={() => removeGroceryFromEvent(index)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
<div className="flex space-x-2">
<button
type="button"
onClick={() => editGroceryFromEvent(index)}
className="text-blue-500 hover:text-blue-700"
>
Edit
</button>
<button
type="button"
onClick={() => removeGroceryFromEvent(index)}
className="text-red-500 hover:text-red-700"
>
Remove
</button>
</div>
</div>
))}
</div>

View File

@ -1,11 +1,16 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ShoppingEvent } from '../types';
import { shoppingEventApi } from '../services/api';
import ConfirmDeleteModal from './ConfirmDeleteModal';
const ShoppingEventList: React.FC = () => {
const navigate = useNavigate();
const [events, setEvents] = useState<ShoppingEvent[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [deletingEvent, setDeletingEvent] = useState<ShoppingEvent | null>(null);
const [deleteLoading, setDeleteLoading] = useState(false);
useEffect(() => {
fetchEvents();
@ -24,6 +29,30 @@ const ShoppingEventList: React.FC = () => {
}
};
const handleDelete = (event: ShoppingEvent) => {
setDeletingEvent(event);
};
const confirmDelete = async () => {
if (!deletingEvent) return;
try {
setDeleteLoading(true);
await shoppingEventApi.delete(deletingEvent.id);
setDeletingEvent(null);
fetchEvents(); // Refresh the list
} catch (err) {
console.error('Error deleting shopping event:', err);
setError('Failed to delete shopping event. Please try again.');
} finally {
setDeleteLoading(false);
}
};
const handleCloseDeleteModal = () => {
setDeletingEvent(null);
};
if (loading) {
return (
<div className="flex justify-center items-center h-64">
@ -36,7 +65,10 @@ const ShoppingEventList: React.FC = () => {
<div className="space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
<button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
<button
onClick={() => navigate('/add-purchase')}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
Add New Event
</button>
</div>
@ -83,8 +115,10 @@ const ShoppingEventList: React.FC = () => {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{event.groceries.map((grocery) => (
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
<span className="text-sm text-gray-900">{grocery.name}</span>
<span className="text-xs text-gray-600 ml-2">${grocery.price}</span>
<div className="text-sm text-gray-900">{grocery.name}</div>
<div className="text-xs text-gray-600">
{grocery.amount} × ${grocery.price.toFixed(2)} = ${(grocery.amount * grocery.price).toFixed(2)}
</div>
</div>
))}
</div>
@ -103,10 +137,16 @@ const ShoppingEventList: React.FC = () => {
Event #{event.id} {new Date(event.created_at).toLocaleDateString()}
</span>
<div className="flex space-x-2">
<button className="text-indigo-600 hover:text-indigo-900">
View Details
<button
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}
className="text-indigo-600 hover:text-indigo-900"
>
Edit
</button>
<button className="text-red-600 hover:text-red-900">
<button
onClick={() => handleDelete(event)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</div>
@ -116,6 +156,15 @@ const ShoppingEventList: React.FC = () => {
</div>
)}
</div>
<ConfirmDeleteModal
isOpen={!!deletingEvent}
onClose={handleCloseDeleteModal}
onConfirm={confirmDelete}
title="Delete Shopping Event"
message={`Are you sure you want to delete this shopping event from ${deletingEvent?.shop.name}? This action cannot be undone.`}
isLoading={deleteLoading}
/>
</div>
);
};

View File

@ -36,7 +36,7 @@ export const shoppingEventApi = {
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
create: (event: ShoppingEventCreate) =>
api.post<ShoppingEvent>('/shopping-events/', event),
update: (id: number, event: Partial<ShoppingEventCreate>) =>
update: (id: number, event: ShoppingEventCreate) =>
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
delete: (id: number) => api.delete(`/shopping-events/${id}`),
};

View File

@ -1,7 +1,6 @@
export interface Grocery {
id: number;
name: string;
price: number;
category: string;
organic: boolean;
weight?: number;
@ -12,7 +11,6 @@ export interface Grocery {
export interface GroceryCreate {
name: string;
price: number;
category: string;
organic: boolean;
weight?: number;
@ -36,6 +34,18 @@ export interface ShopCreate {
export interface GroceryInEvent {
grocery_id: number;
amount: number;
price: number;
}
export interface GroceryWithEventData {
id: number;
name: string;
category: string;
organic: boolean;
weight?: number;
weight_unit: string;
amount: number;
price: number;
}
export interface ShoppingEvent {
@ -46,7 +56,7 @@ export interface ShoppingEvent {
notes?: string;
created_at: string;
shop: Shop;
groceries: Grocery[];
groceries: GroceryWithEventData[];
}
export interface ShoppingEventCreate {