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
This commit is contained in:
parent
2fadb2d991
commit
4f898054ff
@ -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>
|
||||
|
||||
@ -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;
|
||||
@ -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,6 +55,40 @@ 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) {
|
||||
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user