Compare commits

..

2 Commits

Author SHA1 Message Date
b81379432b accept price 0 2025-05-25 20:44:20 +02:00
4f898054ff refactor: Merge add/edit shopping event forms into single component
• Consolidate ShoppingEventForm and EditShoppingEvent into one component
• Use URL parameters to detect add vs edit mode
• Eliminate code duplication while maintaining all functionality
• Remove obsolete EditShoppingEvent.tsx component
2025-05-25 20:42:52 +02:00
3 changed files with 113 additions and 391 deletions

View File

@ -4,7 +4,6 @@ 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() {
@ -65,7 +64,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="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
<Route path="/add-purchase" element={<ShoppingEventForm />} />
</Routes>
</main>

View File

@ -1,367 +0,0 @@
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,13 +1,19 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types';
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
const ShoppingEventForm: 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(false);
const [message, setMessage] = useState('');
const isEditMode = Boolean(id);
const [formData, setFormData] = useState<ShoppingEventCreate>({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
@ -26,7 +32,10 @@ const ShoppingEventForm: React.FC = () => {
useEffect(() => {
fetchShops();
fetchGroceries();
}, []);
if (isEditMode && id) {
fetchShoppingEvent(parseInt(id));
}
}, [id, isEditMode]);
const fetchShops = async () => {
try {
@ -46,8 +55,42 @@ const ShoppingEventForm: React.FC = () => {
}
};
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 addGroceryToEvent = () => {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price > 0) {
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price >= 0) {
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
setNewGroceryItem({ grocery_id: 0, amount: 1, price: 0 });
}
@ -80,21 +123,34 @@ const ShoppingEventForm: React.FC = () => {
groceries: selectedGroceries
};
await shoppingEventApi.create(eventData);
setMessage('Shopping event created successfully!');
// Reset form
setFormData({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
});
setSelectedGroceries([]);
if (isEditMode) {
// Update existing event
console.log('Updating event data:', eventData);
await shoppingEventApi.update(parseInt(id!), eventData);
setMessage('Shopping event updated successfully!');
// Navigate back to shopping events list after a short delay
setTimeout(() => {
navigate('/shopping-events');
}, 1500);
} else {
// Create new event
await shoppingEventApi.create(eventData);
setMessage('Shopping event created successfully!');
// Reset form for add mode
setFormData({
shop_id: 0,
date: new Date().toISOString().split('T')[0],
total_amount: undefined,
notes: '',
groceries: []
});
setSelectedGroceries([]);
}
} catch (error) {
setMessage('Error creating shopping event. Please try again.');
console.error('Error:', error);
console.error('Full error object:', error);
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
} finally {
setLoading(false);
}
@ -105,13 +161,31 @@ const ShoppingEventForm: React.FC = () => {
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">
<h3 className="text-lg leading-6 font-medium text-gray-900 mb-4">
Add New Event
</h3>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg leading-6 font-medium text-gray-900">
{isEditMode ? 'Edit Shopping Event' : 'Add New Event'}
</h3>
{isEditMode && (
<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 ${
@ -282,13 +356,29 @@ const ShoppingEventForm: React.FC = () => {
</div>
{/* Submit Button */}
<div>
<div className="flex justify-end space-x-3">
{isEditMode && (
<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 || selectedGroceries.length === 0}
className="w-full bg-blue-500 hover:bg-blue-700 disabled:bg-gray-300 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline"
className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${
isEditMode
? 'bg-blue-600 hover:bg-blue-700'
: 'w-full bg-blue-500 hover:bg-blue-700 font-bold py-2 px-4 focus:outline-none focus:shadow-outline'
}`}
>
{loading ? 'Creating...' : 'Create Shopping Event'}
{loading
? (isEditMode ? 'Updating...' : 'Creating...')
: (isEditMode ? 'Update Shopping Event' : 'Create Shopping Event')
}
</button>
</div>
</form>