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 import FastAPI, Depends, HTTPException, status
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import text
|
||||||
from typing import List
|
from typing import List
|
||||||
import models, schemas
|
import models, schemas
|
||||||
from database import engine, get_db
|
from database import engine, get_db
|
||||||
@@ -23,6 +24,46 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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
|
# Root endpoint
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
def read_root():
|
def read_root():
|
||||||
@@ -148,25 +189,89 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
|
|||||||
models.shopping_event_groceries.insert().values(
|
models.shopping_event_groceries.insert().values(
|
||||||
shopping_event_id=db_event.id,
|
shopping_event_id=db_event.id,
|
||||||
grocery_id=grocery_item.grocery_id,
|
grocery_id=grocery_item.grocery_id,
|
||||||
amount=grocery_item.amount
|
amount=grocery_item.amount,
|
||||||
|
price=grocery_item.price
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(db_event)
|
db.refresh(db_event)
|
||||||
return db_event
|
return build_shopping_event_response(db_event, db)
|
||||||
|
|
||||||
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
|
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
|
||||||
def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
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()
|
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)
|
@app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
|
||||||
def read_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
def read_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
||||||
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
||||||
if event is None:
|
if event is None:
|
||||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
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
|
# Statistics endpoints
|
||||||
@app.get("/stats/categories", response_model=List[schemas.CategoryStats])
|
@app.get("/stats/categories", response_model=List[schemas.CategoryStats])
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ shopping_event_groceries = Table(
|
|||||||
Base.metadata,
|
Base.metadata,
|
||||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), primary_key=True),
|
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), primary_key=True),
|
||||||
Column('grocery_id', Integer, ForeignKey('groceries.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):
|
class Grocery(Base):
|
||||||
@@ -20,7 +21,6 @@ class Grocery(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
name = Column(String, nullable=False, index=True)
|
name = Column(String, nullable=False, index=True)
|
||||||
price = Column(Float, nullable=False)
|
|
||||||
category = Column(String, nullable=False)
|
category = Column(String, nullable=False)
|
||||||
organic = Column(Boolean, default=False)
|
organic = Column(Boolean, default=False)
|
||||||
weight = Column(Float, nullable=True) # in grams or kg
|
weight = Column(Float, nullable=True) # in grams or kg
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from datetime import datetime
|
|||||||
# Base schemas
|
# Base schemas
|
||||||
class GroceryBase(BaseModel):
|
class GroceryBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
price: float = Field(..., gt=0)
|
|
||||||
category: str
|
category: str
|
||||||
organic: bool = False
|
organic: bool = False
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
@@ -16,7 +15,6 @@ class GroceryCreate(GroceryBase):
|
|||||||
|
|
||||||
class GroceryUpdate(BaseModel):
|
class GroceryUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
price: Optional[float] = Field(None, gt=0)
|
|
||||||
category: Optional[str] = None
|
category: Optional[str] = None
|
||||||
organic: Optional[bool] = None
|
organic: Optional[bool] = None
|
||||||
weight: Optional[float] = None
|
weight: Optional[float] = None
|
||||||
@@ -55,6 +53,20 @@ class Shop(ShopBase):
|
|||||||
class GroceryInEvent(BaseModel):
|
class GroceryInEvent(BaseModel):
|
||||||
grocery_id: int
|
grocery_id: int
|
||||||
amount: float = Field(..., gt=0)
|
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):
|
class ShoppingEventBase(BaseModel):
|
||||||
shop_id: int
|
shop_id: int
|
||||||
@@ -76,7 +88,7 @@ class ShoppingEventResponse(ShoppingEventBase):
|
|||||||
id: int
|
id: int
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
shop: Shop
|
shop: Shop
|
||||||
groceries: List[Grocery] = []
|
groceries: List[GroceryWithEventData] = []
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import GroceryList from './components/GroceryList';
|
|||||||
import ShopList from './components/ShopList';
|
import ShopList from './components/ShopList';
|
||||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||||
import ShoppingEventList from './components/ShoppingEventList';
|
import ShoppingEventList from './components/ShoppingEventList';
|
||||||
|
import EditShoppingEvent from './components/EditShoppingEvent';
|
||||||
import Dashboard from './components/Dashboard';
|
import Dashboard from './components/Dashboard';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -49,7 +50,7 @@ function App() {
|
|||||||
to="/add-purchase"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -64,6 +65,7 @@ function App() {
|
|||||||
<Route path="/groceries" element={<GroceryList />} />
|
<Route path="/groceries" element={<GroceryList />} />
|
||||||
<Route path="/shops" element={<ShopList />} />
|
<Route path="/shops" element={<ShopList />} />
|
||||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||||
|
<Route path="/shopping-events/:id/edit" element={<EditShoppingEvent />} />
|
||||||
<Route path="/add-purchase" element={<ShoppingEventForm />} />
|
<Route path="/add-purchase" element={<ShoppingEventForm />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { groceryApi } from '../services/api';
|
import { groceryApi } from '../services/api';
|
||||||
|
import { Grocery } from '../types';
|
||||||
|
|
||||||
interface AddGroceryModalProps {
|
interface AddGroceryModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onGroceryAdded: () => void;
|
onGroceryAdded: () => void;
|
||||||
|
editGrocery?: Grocery | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface GroceryFormData {
|
interface GroceryFormData {
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
|
||||||
category: string;
|
category: string;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
weight_unit: string;
|
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>({
|
const [formData, setFormData] = useState<GroceryFormData>({
|
||||||
name: '',
|
name: '',
|
||||||
price: 0,
|
|
||||||
category: '',
|
category: '',
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
@@ -35,9 +35,32 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
|||||||
|
|
||||||
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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');
|
setError('Please fill in all required fields with valid values');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -51,12 +74,17 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
|||||||
weight: formData.weight || undefined
|
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
|
// Reset form
|
||||||
setFormData({
|
setFormData({
|
||||||
name: '',
|
name: '',
|
||||||
price: 0,
|
|
||||||
category: '',
|
category: '',
|
||||||
organic: false,
|
organic: false,
|
||||||
weight: undefined,
|
weight: undefined,
|
||||||
@@ -66,8 +94,8 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
|||||||
onGroceryAdded();
|
onGroceryAdded();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError('Failed to add grocery. Please try again.');
|
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`);
|
||||||
console.error('Error adding grocery:', err);
|
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
|
||||||
<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">Add New Grocery</h3>
|
<h3 className="text-lg font-medium text-gray-900">
|
||||||
|
{editGrocery ? 'Edit Grocery' : 'Add New Grocery'}
|
||||||
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-gray-400 hover:text-gray-600"
|
className="text-gray-400 hover:text-gray-600"
|
||||||
@@ -124,24 +154,6 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
<label htmlFor="category" className="block text-sm font-medium text-gray-700">
|
||||||
Category *
|
Category *
|
||||||
@@ -223,7 +235,10 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
|||||||
disabled={loading}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -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 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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
@@ -74,19 +101,25 @@ const Dashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<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 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">
|
<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">
|
<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" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="text-sm text-gray-600">Record a new shopping event</p>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
</div>
|
||||||
</button>
|
</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">
|
<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">
|
<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" />
|
<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>
|
<h2 className="text-lg font-medium text-gray-900">Recent Shopping Events</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
<div className="text-center py-8">
|
{loading ? (
|
||||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
<div className="flex justify-center items-center py-8">
|
||||||
<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" />
|
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
</svg>
|
</div>
|
||||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No shopping events yet</h3>
|
) : recentEvents.length === 0 ? (
|
||||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first purchase!</p>
|
<div className="text-center py-8">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
367
frontend/src/components/EditShoppingEvent.tsx
Normal file
367
frontend/src/components/EditShoppingEvent.tsx
Normal 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;
|
||||||
@@ -1,17 +1,30 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
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 [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchGroceries();
|
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 () => {
|
const fetchGroceries = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -26,15 +39,28 @@ const GroceryList: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleEdit = (grocery: Grocery) => {
|
||||||
if (window.confirm('Are you sure you want to delete this grocery item?')) {
|
setEditingGrocery(grocery);
|
||||||
try {
|
setIsModalOpen(true);
|
||||||
await groceryApi.delete(id);
|
};
|
||||||
setGroceries(groceries.filter(g => g.id !== id));
|
|
||||||
} catch (err) {
|
const handleDelete = (grocery: Grocery) => {
|
||||||
setError('Failed to delete grocery');
|
setDeletingGrocery(grocery);
|
||||||
console.error('Error deleting grocery:', err);
|
};
|
||||||
}
|
|
||||||
|
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
|
fetchGroceries(); // Refresh the list
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleCloseModal = () => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setEditingGrocery(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDeleteModal = () => {
|
||||||
|
setDeletingGrocery(null);
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -55,7 +90,10 @@ const GroceryList: 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">Groceries</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||||
<button
|
<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"
|
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||||
>
|
>
|
||||||
Add New Grocery
|
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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Name
|
Name
|
||||||
</th>
|
</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">
|
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
Category
|
Category
|
||||||
</th>
|
</th>
|
||||||
@@ -107,9 +142,6 @@ const GroceryList: React.FC = () => {
|
|||||||
<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">{grocery.name}</div>
|
<div className="text-sm font-medium text-gray-900">{grocery.name}</div>
|
||||||
</td>
|
</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">
|
<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">
|
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||||
{grocery.category}
|
{grocery.category}
|
||||||
@@ -128,11 +160,14 @@ const GroceryList: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</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 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
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(grocery.id)}
|
onClick={() => handleDelete(grocery)}
|
||||||
className="text-red-600 hover:text-red-900"
|
className="text-red-600 hover:text-red-900"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -147,8 +182,18 @@ const GroceryList: React.FC = () => {
|
|||||||
|
|
||||||
<AddGroceryModal
|
<AddGroceryModal
|
||||||
isOpen={isModalOpen}
|
isOpen={isModalOpen}
|
||||||
onClose={() => setIsModalOpen(false)}
|
onClose={handleCloseModal}
|
||||||
onGroceryAdded={handleGroceryAdded}
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
import { Shop } from '../types';
|
import { Shop } from '../types';
|
||||||
import { shopApi } from '../services/api';
|
import { shopApi } from '../services/api';
|
||||||
import AddShopModal from './AddShopModal';
|
import AddShopModal from './AddShopModal';
|
||||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||||
|
|
||||||
const ShopList: React.FC = () => {
|
const ShopList: React.FC = () => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [shops, setShops] = useState<Shop[]>([]);
|
const [shops, setShops] = useState<Shop[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -15,7 +17,14 @@ const ShopList: React.FC = () => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchShops();
|
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 () => {
|
const fetchShops = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
|
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
|
||||||
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
|
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
|
||||||
grocery_id: 0,
|
grocery_id: 0,
|
||||||
amount: 1
|
amount: 1,
|
||||||
|
price: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -46,9 +47,9 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const addGroceryToEvent = () => {
|
const addGroceryToEvent = () => {
|
||||||
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0) {
|
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price > 0) {
|
||||||
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
|
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));
|
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) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -97,7 +110,7 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
<div className="bg-white shadow rounded-lg">
|
<div className="bg-white shadow rounded-lg">
|
||||||
<div className="px-4 py-5 sm:p-6">
|
<div className="px-4 py-5 sm:p-6">
|
||||||
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
|
||||||
Add New Purchase
|
Add New Event
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
{message && (
|
{message && (
|
||||||
@@ -151,34 +164,60 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
Add Groceries
|
Add Groceries
|
||||||
</label>
|
</label>
|
||||||
<div className="flex space-x-2 mb-4">
|
<div className="flex space-x-2 mb-4">
|
||||||
<select
|
<div className="flex-1">
|
||||||
value={newGroceryItem.grocery_id}
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
|
Grocery
|
||||||
className="flex-1 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
</label>
|
||||||
>
|
<select
|
||||||
<option value={0}>Select a grocery</option>
|
value={newGroceryItem.grocery_id}
|
||||||
{groceries.map(grocery => (
|
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
|
||||||
<option key={grocery.id} value={grocery.id}>
|
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
{grocery.name} - ${grocery.price} ({grocery.category})
|
>
|
||||||
</option>
|
<option value={0}>Select a grocery</option>
|
||||||
))}
|
{groceries.map(grocery => (
|
||||||
</select>
|
<option key={grocery.id} value={grocery.id}>
|
||||||
<input
|
{grocery.name} ({grocery.category})
|
||||||
type="number"
|
</option>
|
||||||
step="0.1"
|
))}
|
||||||
min="0.1"
|
</select>
|
||||||
placeholder="Amount"
|
</div>
|
||||||
value={newGroceryItem.amount}
|
<div className="w-24">
|
||||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
|
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||||
className="w-24 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
Amount
|
||||||
/>
|
</label>
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="number"
|
||||||
onClick={addGroceryToEvent}
|
step="1"
|
||||||
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
|
min="1"
|
||||||
>
|
placeholder="1"
|
||||||
Add
|
value={newGroceryItem.amount}
|
||||||
</button>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Selected Groceries List */}
|
{/* Selected Groceries List */}
|
||||||
@@ -188,15 +227,24 @@ const ShoppingEventForm: React.FC = () => {
|
|||||||
{selectedGroceries.map((item, index) => (
|
{selectedGroceries.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">
|
||||||
<span>
|
<span>
|
||||||
{getGroceryName(item.grocery_id)} x {item.amount}
|
{getGroceryName(item.grocery_id)} x {item.amount} @ ${item.price.toFixed(2)}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<div className="flex space-x-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => removeGroceryFromEvent(index)}
|
type="button"
|
||||||
className="text-red-500 hover:text-red-700"
|
onClick={() => editGroceryFromEvent(index)}
|
||||||
>
|
className="text-blue-500 hover:text-blue-700"
|
||||||
Remove
|
>
|
||||||
</button>
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeGroceryFromEvent(index)}
|
||||||
|
className="text-red-500 hover:text-red-700"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } 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';
|
||||||
|
|
||||||
const ShoppingEventList: React.FC = () => {
|
const ShoppingEventList: React.FC = () => {
|
||||||
|
const navigate = useNavigate();
|
||||||
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 [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEvents();
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center h-64">
|
<div className="flex justify-center items-center h-64">
|
||||||
@@ -36,7 +65,10 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<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 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
|
Add New Event
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||||
{event.groceries.map((grocery) => (
|
{event.groceries.map((grocery) => (
|
||||||
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
|
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
|
||||||
<span className="text-sm text-gray-900">{grocery.name}</span>
|
<div className="text-sm text-gray-900">{grocery.name}</div>
|
||||||
<span className="text-xs text-gray-600 ml-2">${grocery.price}</span>
|
<div className="text-xs text-gray-600">
|
||||||
|
{grocery.amount} × ${grocery.price.toFixed(2)} = ${(grocery.amount * grocery.price).toFixed(2)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -103,10 +137,16 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
Event #{event.id} • {new Date(event.created_at).toLocaleDateString()}
|
Event #{event.id} • {new Date(event.created_at).toLocaleDateString()}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<button className="text-indigo-600 hover:text-indigo-900">
|
<button
|
||||||
View Details
|
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}
|
||||||
|
className="text-indigo-600 hover:text-indigo-900"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button className="text-red-600 hover:text-red-900">
|
<button
|
||||||
|
onClick={() => handleDelete(event)}
|
||||||
|
className="text-red-600 hover:text-red-900"
|
||||||
|
>
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,6 +156,15 @@ const ShoppingEventList: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export const shoppingEventApi = {
|
|||||||
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
|
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
|
||||||
create: (event: ShoppingEventCreate) =>
|
create: (event: ShoppingEventCreate) =>
|
||||||
api.post<ShoppingEvent>('/shopping-events/', event),
|
api.post<ShoppingEvent>('/shopping-events/', event),
|
||||||
update: (id: number, event: Partial<ShoppingEventCreate>) =>
|
update: (id: number, event: ShoppingEventCreate) =>
|
||||||
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
|
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
|
||||||
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
export interface Grocery {
|
export interface Grocery {
|
||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
|
||||||
category: string;
|
category: string;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
@@ -12,7 +11,6 @@ export interface Grocery {
|
|||||||
|
|
||||||
export interface GroceryCreate {
|
export interface GroceryCreate {
|
||||||
name: string;
|
name: string;
|
||||||
price: number;
|
|
||||||
category: string;
|
category: string;
|
||||||
organic: boolean;
|
organic: boolean;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
@@ -36,6 +34,18 @@ export interface ShopCreate {
|
|||||||
export interface GroceryInEvent {
|
export interface GroceryInEvent {
|
||||||
grocery_id: number;
|
grocery_id: number;
|
||||||
amount: 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 {
|
export interface ShoppingEvent {
|
||||||
@@ -46,7 +56,7 @@ export interface ShoppingEvent {
|
|||||||
notes?: string;
|
notes?: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
shop: Shop;
|
shop: Shop;
|
||||||
groceries: Grocery[];
|
groceries: GroceryWithEventData[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ShoppingEventCreate {
|
export interface ShoppingEventCreate {
|
||||||
|
|||||||
Reference in New Issue
Block a user