Stardize frontend layout
This commit is contained in:
472
frontend/src/components/AddShoppingEventModal.tsx
Normal file
472
frontend/src/components/AddShoppingEventModal.tsx
Normal file
@@ -0,0 +1,472 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent } from '../types';
|
||||
import { shopApi, productApi, shoppingEventApi } from '../services/api';
|
||||
|
||||
interface AddShoppingEventModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onEventAdded: () => void;
|
||||
editEvent?: ShoppingEvent | null;
|
||||
}
|
||||
|
||||
const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onEventAdded,
|
||||
editEvent
|
||||
}) => {
|
||||
const [shops, setShops] = useState<Shop[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const isEditMode = Boolean(editEvent);
|
||||
|
||||
const [formData, setFormData] = useState<ShoppingEventCreate>({
|
||||
shop_id: 0,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
total_amount: undefined,
|
||||
notes: '',
|
||||
products: []
|
||||
});
|
||||
|
||||
const [selectedProducts, setSelectedProducts] = useState<ProductInEvent[]>([]);
|
||||
const [newProductItem, setNewProductItem] = useState<ProductInEvent>({
|
||||
product_id: 0,
|
||||
amount: 1,
|
||||
price: 0
|
||||
});
|
||||
const [autoCalculate, setAutoCalculate] = useState<boolean>(true);
|
||||
|
||||
// Calculate total amount from selected products
|
||||
const calculateTotal = (products: ProductInEvent[]): number => {
|
||||
const total = products.reduce((total, item) => total + (item.amount * item.price), 0);
|
||||
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
||||
};
|
||||
|
||||
const loadEventData = useCallback(() => {
|
||||
if (editEvent) {
|
||||
// Use the date directly if it's already in YYYY-MM-DD format, otherwise format it
|
||||
let formattedDate = editEvent.date;
|
||||
if (editEvent.date.includes('T') || editEvent.date.length > 10) {
|
||||
// If the date includes time or is longer than YYYY-MM-DD, extract just the date part
|
||||
formattedDate = editEvent.date.split('T')[0];
|
||||
}
|
||||
|
||||
// Map products to the format we need
|
||||
const mappedProducts = editEvent.products.map(p => ({
|
||||
product_id: p.id,
|
||||
amount: p.amount,
|
||||
price: p.price
|
||||
}));
|
||||
|
||||
// Calculate the sum of all products
|
||||
const calculatedTotal = calculateTotal(mappedProducts);
|
||||
|
||||
// Check if existing total matches calculated total (with small tolerance for floating point)
|
||||
const existingTotal = editEvent.total_amount || 0;
|
||||
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
|
||||
|
||||
setFormData({
|
||||
shop_id: editEvent.shop.id,
|
||||
date: formattedDate,
|
||||
total_amount: editEvent.total_amount,
|
||||
notes: editEvent.notes || '',
|
||||
products: []
|
||||
});
|
||||
|
||||
setSelectedProducts(mappedProducts);
|
||||
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
||||
} else {
|
||||
// Reset form for adding new event
|
||||
setFormData({
|
||||
shop_id: 0,
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
total_amount: undefined,
|
||||
notes: '',
|
||||
products: []
|
||||
});
|
||||
setSelectedProducts([]);
|
||||
setAutoCalculate(true);
|
||||
}
|
||||
setMessage('');
|
||||
}, [editEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchShops();
|
||||
fetchProducts();
|
||||
loadEventData();
|
||||
}
|
||||
}, [isOpen, loadEventData]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
// Only trigger submit if not in a textarea and form is valid
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
if (formData.shop_id > 0 && selectedProducts.length > 0) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, formData, selectedProducts, loading, onClose]);
|
||||
|
||||
// Update total amount whenever selectedProducts changes
|
||||
useEffect(() => {
|
||||
if (autoCalculate) {
|
||||
const calculatedTotal = calculateTotal(selectedProducts);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
total_amount: calculatedTotal > 0 ? calculatedTotal : undefined
|
||||
}));
|
||||
}
|
||||
}, [selectedProducts, autoCalculate]);
|
||||
|
||||
const fetchShops = async () => {
|
||||
try {
|
||||
const response = await shopApi.getAll();
|
||||
setShops(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching shops:', error);
|
||||
setMessage('Error loading shops. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const response = await productApi.getAll();
|
||||
setProducts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
setMessage('Error loading products. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
const addProductToEvent = () => {
|
||||
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
||||
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
||||
setNewProductItem({ product_id: 0, amount: 1, price: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const removeProductFromEvent = (index: number) => {
|
||||
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const editProductFromEvent = (index: number) => {
|
||||
const productToEdit = selectedProducts[index];
|
||||
// Load the product data into the input fields
|
||||
setNewProductItem({
|
||||
product_id: productToEdit.product_id,
|
||||
amount: productToEdit.amount,
|
||||
price: productToEdit.price
|
||||
});
|
||||
// Remove the item from the selected list
|
||||
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setMessage('');
|
||||
|
||||
try {
|
||||
const eventData = {
|
||||
...formData,
|
||||
products: selectedProducts
|
||||
};
|
||||
|
||||
if (isEditMode && editEvent) {
|
||||
await shoppingEventApi.update(editEvent.id, eventData);
|
||||
setMessage('Shopping event updated successfully!');
|
||||
} else {
|
||||
await shoppingEventApi.create(eventData);
|
||||
setMessage('Shopping event created successfully!');
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onEventAdded();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Error saving shopping event:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getProductName = (id: number) => {
|
||||
const product = products.find(p => p.id === id);
|
||||
if (!product) return 'Unknown';
|
||||
|
||||
const weightInfo = product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit;
|
||||
const organicEmoji = product.organic ? ' 🌱' : '';
|
||||
return `${product.name}${organicEmoji} ${weightInfo}`;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div className="relative top-10 mx-auto p-5 border w-full max-w-4xl shadow-lg rounded-md bg-white">
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{isEditMode ? 'Edit Shopping Event' : 'Add New Shopping Event'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 px-4 py-3 rounded ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 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 Products Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Add Products
|
||||
</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">
|
||||
Product
|
||||
</label>
|
||||
<select
|
||||
value={newProductItem.product_id}
|
||||
onChange={(e) => setNewProductItem({...newProductItem, product_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 product</option>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}{product.organic ? '🌱' : ''} ({product.grocery.category.name}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||
</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={newProductItem.amount}
|
||||
onChange={(e) => setNewProductItem({...newProductItem, 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={newProductItem.price}
|
||||
onChange={(e) => setNewProductItem({...newProductItem, 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={addProductToEvent}
|
||||
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Products List */}
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-md p-4 max-h-40 overflow-y-auto">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
||||
{selectedProducts.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-gray-900">
|
||||
{getProductName(item.product_id)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editProductFromEvent(index)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeProductFromEvent(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Total Amount */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<label className="block text-sm font-medium text-gray-700">
|
||||
Total Amount
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<label className="relative inline-flex items-center cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoCalculate}
|
||||
onChange={(e) => setAutoCalculate(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-9 h-5 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-blue-600"></div>
|
||||
</label>
|
||||
<span className={`text-xs px-2 py-1 rounded ${autoCalculate ? 'text-green-600 bg-green-50' : 'text-gray-500 bg-gray-100'}`}>
|
||||
Auto-calculated
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={formData.total_amount || ''}
|
||||
onChange={(e) => {
|
||||
setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined});
|
||||
setAutoCalculate(false); // Disable auto-calculation when manually editing
|
||||
}}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-right"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{autoCalculate
|
||||
? "This field is automatically calculated from your selected items. You can manually edit it if needed."
|
||||
: "Auto-calculation is disabled. You can manually enter the total amount or enable auto-calculation above."
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Notes (optional)
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({...formData, notes: 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"
|
||||
placeholder="Any additional notes about this purchase..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="flex justify-end space-x-3 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
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 || selectedProducts.length === 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
|
||||
? (isEditMode ? 'Updating...' : 'Creating...')
|
||||
: (isEditMode ? 'Update Shopping Event' : 'Create Shopping Event')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddShoppingEventModal;
|
||||
Reference in New Issue
Block a user