groceries/frontend/src/components/ShoppingEventForm.tsx
2025-05-26 20:20:21 +02:00

454 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
import { shopApi, productApi, shoppingEventApi } from '../services/api';
const ShoppingEventForm: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const [shops, setShops] = useState<Shop[]>([]);
const [products, setProducts] = useState<Product[]>([]);
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],
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);
useEffect(() => {
fetchShops();
fetchProducts();
if (isEditMode && id) {
fetchShoppingEvent(parseInt(id));
}
}, [id, isEditMode]);
// 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
};
// 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);
}
};
const fetchProducts = async () => {
try {
const response = await productApi.getAll();
setProducts(response.data);
} catch (error) {
console.error('Error fetching products:', error);
}
};
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];
}
// Map products to the format we need
const mappedProducts = event.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 = event.total_amount || 0;
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
setFormData({
shop_id: event.shop.id,
date: formattedDate,
total_amount: event.total_amount,
notes: event.notes || '',
products: []
});
setSelectedProducts(mappedProducts);
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
} catch (error) {
console.error('Error fetching shopping event:', error);
setMessage('Error loading shopping event. Please try again.');
} finally {
setLoadingEvent(false);
}
};
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) {
// 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: '',
products: []
});
setSelectedProducts([]);
}
} catch (error) {
console.error('Full error object:', error);
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
} 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 (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">
{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 ${
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 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.category}) {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">
<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">
{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 || selectedProducts.length === 0}
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
? (isEditMode ? 'Updating...' : 'Creating...')
: (isEditMode ? 'Update Shopping Event' : 'Create Shopping Event')
}
</button>
</div>
</form>
</div>
</div>
</div>
);
};
export default ShoppingEventForm;