454 lines
18 KiB
TypeScript
454 lines
18 KiB
TypeScript
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;
|