Stardize frontend layout
This commit is contained in:
parent
e20d0f0524
commit
629a89524c
@ -4,7 +4,6 @@ import Dashboard from './components/Dashboard';
|
||||
import ShopList from './components/ShopList';
|
||||
import ProductList from './components/ProductList';
|
||||
import ShoppingEventList from './components/ShoppingEventList';
|
||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||
import BrandList from './components/BrandList';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import GroceryCategoryList from './components/GroceryCategoryList';
|
||||
@ -108,8 +107,6 @@ function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||
<Route path="/shopping-events/new" element={<ShoppingEventForm />} />
|
||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/products" element={<ProductList />} />
|
||||
<Route path="/brands" element={<BrandList />} />
|
||||
|
||||
@ -36,6 +36,26 @@ const AddBrandModal: React.FC<AddBrandModalProps> = ({ isOpen, onClose, onBrandA
|
||||
setError('');
|
||||
}, [editBrand, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
event.preventDefault();
|
||||
if (formData.name.trim()) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, formData, loading, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim()) {
|
||||
|
||||
@ -24,6 +24,24 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
||||
}
|
||||
}, [category]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
event.preventDefault();
|
||||
if (formData.name.trim()) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [formData, loading, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
@ -44,6 +62,7 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
||||
} catch (error) {
|
||||
console.error('Error saving category:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} category. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -58,10 +77,10 @@ const AddGroceryCategoryModal: React.FC<AddGroceryCategoryModalProps> = ({ categ
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
<div className={`mb-4 px-4 py-3 rounded ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
@ -28,6 +28,24 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
||||
}
|
||||
}, [grocery]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
event.preventDefault();
|
||||
if (formData.name.trim() && formData.category_id > 0) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [formData, loading, onClose]);
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
const response = await groceryCategoryApi.getAll();
|
||||
@ -35,6 +53,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
||||
} catch (error) {
|
||||
console.error('Error fetching categories:', error);
|
||||
setMessage('Error loading categories. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
@ -58,6 +77,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
||||
} catch (error) {
|
||||
console.error('Error saving grocery:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} grocery. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -72,10 +92,10 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ grocery, onClose }) =
|
||||
</h3>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
<div className={`mb-4 px-4 py-3 rounded ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
|
||||
@ -85,6 +85,26 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
|
||||
setError('');
|
||||
}, [editProduct, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !event.shiftKey && !loading) {
|
||||
event.preventDefault();
|
||||
if (formData.name.trim() && formData.grocery_id) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, formData, loading, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim() || !formData.grocery_id) {
|
||||
|
||||
@ -44,6 +44,30 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
|
||||
setError('');
|
||||
}, [editShop, isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
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.name.trim() && formData.city.trim()) {
|
||||
handleSubmit(event as any);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, formData, loading, onClose]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!formData.name.trim() || !formData.city.trim()) {
|
||||
|
||||
@ -1,18 +1,26 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
|
||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent } from '../types';
|
||||
import { shopApi, productApi, shoppingEventApi } from '../services/api';
|
||||
|
||||
const ShoppingEventForm: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
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 [loadingEvent, setLoadingEvent] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const isEditMode = Boolean(id);
|
||||
const isEditMode = Boolean(editEvent);
|
||||
|
||||
const [formData, setFormData] = useState<ShoppingEventCreate>({
|
||||
shop_id: 0,
|
||||
@ -36,21 +44,17 @@ const ShoppingEventForm: React.FC = () => {
|
||||
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
||||
};
|
||||
|
||||
const fetchShoppingEvent = useCallback(async (eventId: number) => {
|
||||
try {
|
||||
setLoadingEvent(true);
|
||||
const response = await shoppingEventApi.getById(eventId);
|
||||
const event = response.data;
|
||||
|
||||
const loadEventData = useCallback(() => {
|
||||
if (editEvent) {
|
||||
// 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) {
|
||||
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 = event.date.split('T')[0];
|
||||
formattedDate = editEvent.date.split('T')[0];
|
||||
}
|
||||
|
||||
// Map products to the format we need
|
||||
const mappedProducts = event.products.map(p => ({
|
||||
const mappedProducts = editEvent.products.map(p => ({
|
||||
product_id: p.id,
|
||||
amount: p.amount,
|
||||
price: p.price
|
||||
@ -60,34 +64,68 @@ const ShoppingEventForm: React.FC = () => {
|
||||
const calculatedTotal = calculateTotal(mappedProducts);
|
||||
|
||||
// Check if existing total matches calculated total (with small tolerance for floating point)
|
||||
const existingTotal = event.total_amount || 0;
|
||||
const existingTotal = editEvent.total_amount || 0;
|
||||
const totalMatches = Math.abs(existingTotal - calculatedTotal) < 0.01;
|
||||
|
||||
setFormData({
|
||||
shop_id: event.shop.id,
|
||||
shop_id: editEvent.shop.id,
|
||||
date: formattedDate,
|
||||
total_amount: event.total_amount,
|
||||
notes: event.notes || '',
|
||||
total_amount: editEvent.total_amount,
|
||||
notes: editEvent.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);
|
||||
} 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(() => {
|
||||
fetchShops();
|
||||
fetchProducts();
|
||||
if (isEditMode && id) {
|
||||
fetchShoppingEvent(parseInt(id));
|
||||
if (isOpen) {
|
||||
fetchShops();
|
||||
fetchProducts();
|
||||
loadEventData();
|
||||
}
|
||||
}, [id, isEditMode, fetchShoppingEvent]);
|
||||
}, [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(() => {
|
||||
@ -106,6 +144,8 @@ const ShoppingEventForm: React.FC = () => {
|
||||
setShops(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching shops:', error);
|
||||
setMessage('Error loading shops. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
@ -115,6 +155,8 @@ const ShoppingEventForm: React.FC = () => {
|
||||
setProducts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching products:', error);
|
||||
setMessage('Error loading products. Please try again.');
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
@ -152,34 +194,22 @@ const ShoppingEventForm: React.FC = () => {
|
||||
products: selectedProducts
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
// Update existing event
|
||||
console.log('Updating event data:', eventData);
|
||||
await shoppingEventApi.update(parseInt(id!), eventData);
|
||||
if (isEditMode && editEvent) {
|
||||
await shoppingEventApi.update(editEvent.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([]);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
onEventAdded();
|
||||
onClose();
|
||||
}, 1500);
|
||||
} catch (error) {
|
||||
console.error('Full error object:', error);
|
||||
console.error('Error saving shopping event:', error);
|
||||
setMessage(`Error ${isEditMode ? 'updating' : 'creating'} shopping event. Please try again.`);
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -194,37 +224,31 @@ const ShoppingEventForm: React.FC = () => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (!isOpen) return null;
|
||||
|
||||
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="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 leading-6 font-medium text-gray-900">
|
||||
{isEditMode ? 'Edit Shopping Event' : 'Add New Event'}
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{isEditMode ? 'Edit Shopping Event' : 'Add New Shopping Event'}
|
||||
</h3>
|
||||
{isEditMode && (
|
||||
<button
|
||||
onClick={() => navigate('/shopping-events')}
|
||||
className="text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
← Back to Shopping Events
|
||||
</button>
|
||||
)}
|
||||
<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 p-4 rounded-md ${
|
||||
<div className={`mb-4 px-4 py-3 rounded ${
|
||||
message.includes('Error')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
@ -329,7 +353,7 @@ const ShoppingEventForm: React.FC = () => {
|
||||
|
||||
{/* Selected Products List */}
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<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">
|
||||
@ -419,24 +443,18 @@ const ShoppingEventForm: React.FC = () => {
|
||||
</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>
|
||||
)}
|
||||
<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 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'
|
||||
}`}
|
||||
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...')
|
||||
@ -451,4 +469,4 @@ const ShoppingEventForm: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ShoppingEventForm;
|
||||
export default AddShoppingEventModal;
|
||||
@ -118,47 +118,55 @@ const BrandList: React.FC = () => {
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first brand.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
{brands.map((brand) => (
|
||||
<div key={brand.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{brand.name}</h3>
|
||||
<div className="flex space-x-2">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Updated
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{brands.map((brand) => (
|
||||
<tr key={brand.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{brand.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(brand.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{brand.updated_at ? new Date(brand.updated_at).toLocaleDateString() : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEditBrand(brand)}
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBrand(brand)}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Added {new Date(brand.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{brand.updated_at && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Updated {new Date(brand.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
interface ConfirmDeleteModalProps {
|
||||
isOpen: boolean;
|
||||
@ -17,6 +17,24 @@ const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({
|
||||
message,
|
||||
isLoading = false
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onClose();
|
||||
} else if (event.key === 'Enter' && !isLoading) {
|
||||
event.preventDefault();
|
||||
onConfirm();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [isOpen, isLoading, onClose, onConfirm]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
|
||||
@ -102,7 +102,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/shopping-events/new')}
|
||||
onClick={() => navigate('/shopping-events?add=true')}
|
||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { GroceryCategory } from '../types';
|
||||
import { groceryCategoryApi } from '../services/api';
|
||||
import AddGroceryCategoryModal from './AddGroceryCategoryModal';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
|
||||
const GroceryCategoryList: React.FC = () => {
|
||||
const [categories, setCategories] = useState<GroceryCategory[]>([]);
|
||||
@ -9,6 +10,8 @@ const GroceryCategoryList: React.FC = () => {
|
||||
const [message, setMessage] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingCategory, setEditingCategory] = useState<GroceryCategory | null>(null);
|
||||
const [deletingCategory, setDeletingCategory] = useState<GroceryCategory | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories();
|
||||
@ -27,25 +30,37 @@ const GroceryCategoryList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this category?')) {
|
||||
try {
|
||||
await groceryCategoryApi.delete(id);
|
||||
setMessage('Category deleted successfully!');
|
||||
fetchCategories();
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting category:', error);
|
||||
if (error.response?.status === 400) {
|
||||
setMessage('Cannot delete category: groceries are still associated with this category.');
|
||||
} else {
|
||||
setMessage('Error deleting category. Please try again.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 5000);
|
||||
const handleDelete = async (category: GroceryCategory) => {
|
||||
setDeletingCategory(category);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingCategory) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await groceryCategoryApi.delete(deletingCategory.id);
|
||||
setMessage('Category deleted successfully!');
|
||||
setDeletingCategory(null);
|
||||
fetchCategories();
|
||||
setTimeout(() => setMessage(''), 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting category:', error);
|
||||
if (error.response?.status === 400) {
|
||||
setMessage('Cannot delete category: groceries are still associated with this category.');
|
||||
} else {
|
||||
setMessage('Error deleting category. Please try again.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setDeletingCategory(null);
|
||||
};
|
||||
|
||||
const handleEdit = (category: GroceryCategory) => {
|
||||
setEditingCategory(category);
|
||||
setIsModalOpen(true);
|
||||
@ -66,81 +81,81 @@ const GroceryCategoryList: React.FC = () => {
|
||||
}
|
||||
|
||||
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">
|
||||
Grocery Categories
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add Category
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Grocery Categories</h1>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Category
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No categories found. Add your first category!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(category.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className={`px-4 py-3 rounded ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{categories.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a1.994 1.994 0 01-1.414.586H7m0-18v18m0-18h.01" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No categories</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first category.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{categories.map((category) => (
|
||||
<tr key={category.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{category.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(category.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(category)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
@ -149,6 +164,15 @@ const GroceryCategoryList: React.FC = () => {
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={!!deletingCategory}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Category"
|
||||
message={`Are you sure you want to delete "${deletingCategory?.name}"? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { Grocery } from '../types';
|
||||
import { groceryApi } from '../services/api';
|
||||
import AddGroceryModal from './AddGroceryModal';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
|
||||
const GroceryList: React.FC = () => {
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
@ -9,6 +10,8 @@ const GroceryList: React.FC = () => {
|
||||
const [message, setMessage] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
||||
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroceries();
|
||||
@ -27,25 +30,37 @@ const GroceryList: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (window.confirm('Are you sure you want to delete this grocery?')) {
|
||||
try {
|
||||
await groceryApi.delete(id);
|
||||
setMessage('Grocery deleted successfully!');
|
||||
fetchGroceries();
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting grocery:', error);
|
||||
if (error.response?.status === 400) {
|
||||
setMessage('Cannot delete grocery: products are still associated with this grocery.');
|
||||
} else {
|
||||
setMessage('Error deleting grocery. Please try again.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 5000);
|
||||
const handleDelete = async (grocery: Grocery) => {
|
||||
setDeletingGrocery(grocery);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGrocery) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await groceryApi.delete(deletingGrocery.id);
|
||||
setMessage('Grocery deleted successfully!');
|
||||
setDeletingGrocery(null);
|
||||
fetchGroceries();
|
||||
setTimeout(() => setMessage(''), 1500);
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting grocery:', error);
|
||||
if (error.response?.status === 400) {
|
||||
setMessage('Cannot delete grocery: products are still associated with this grocery.');
|
||||
} else {
|
||||
setMessage('Error deleting grocery. Please try again.');
|
||||
}
|
||||
setTimeout(() => setMessage(''), 3000);
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setDeletingGrocery(null);
|
||||
};
|
||||
|
||||
const handleEdit = (grocery: Grocery) => {
|
||||
setEditingGrocery(grocery);
|
||||
setIsModalOpen(true);
|
||||
@ -66,87 +81,87 @@ const GroceryList: React.FC = () => {
|
||||
}
|
||||
|
||||
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">
|
||||
Groceries
|
||||
</h3>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add Grocery
|
||||
</button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||
<button
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Grocery
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`mb-4 p-4 rounded-md ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-green-50 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groceries.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No groceries found. Add your first grocery!</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 md:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-gray-300">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{groceries.map((grocery) => (
|
||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||
{grocery.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{grocery.category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(grocery.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(grocery)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-4"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(grocery.id)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{message && (
|
||||
<div className={`px-4 py-3 rounded ${
|
||||
message.includes('Error') || message.includes('Cannot')
|
||||
? 'bg-red-50 border border-red-200 text-red-700'
|
||||
: 'bg-green-50 border border-green-200 text-green-700'
|
||||
}`}>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{groceries.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Category
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{groceries.map((grocery) => (
|
||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{grocery.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{grocery.category.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(grocery.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(grocery)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(grocery)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isModalOpen && (
|
||||
@ -155,6 +170,15 @@ const GroceryList: React.FC = () => {
|
||||
onClose={handleModalClose}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={!!deletingGrocery}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Grocery"
|
||||
message={`Are you sure you want to delete "${deletingGrocery?.name}"? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -113,64 +113,61 @@ const ShopList: React.FC = () => {
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first shop.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 p-6">
|
||||
{shops.map((shop) => (
|
||||
<div key={shop.id} className="bg-white border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">{shop.name}</h3>
|
||||
<div className="flex space-x-2">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Name
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
City
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Address
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{shops.map((shop) => (
|
||||
<tr key={shop.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{shop.name}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{shop.city}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{shop.address || '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(shop.created_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEditShop(shop)}
|
||||
className="text-indigo-600 hover:text-indigo-900 text-sm"
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteShop(shop)}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
{shop.city}
|
||||
</div>
|
||||
|
||||
{shop.address && (
|
||||
<div className="flex items-start text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 7.89a2 2 0 002.83 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
{shop.address}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
Added {new Date(shop.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
{shop.updated_at && (
|
||||
<div className="flex items-center text-sm text-gray-600">
|
||||
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Updated {new Date(shop.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,20 +1,52 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ShoppingEvent } from '../types';
|
||||
import { shoppingEventApi } from '../services/api';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
import AddShoppingEventModal from './AddShoppingEventModal';
|
||||
|
||||
const ShoppingEventList: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [events, setEvents] = useState<ShoppingEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [deletingEvent, setDeletingEvent] = useState<ShoppingEvent | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState<ShoppingEvent | null>(null);
|
||||
const [hoveredEvent, setHoveredEvent] = useState<ShoppingEvent | null>(null);
|
||||
const [showItemsPopup, setShowItemsPopup] = useState(false);
|
||||
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
}, []);
|
||||
|
||||
// Check if we should auto-open the modal
|
||||
if (searchParams.get('add') === 'true') {
|
||||
setIsModalOpen(true);
|
||||
// Remove the parameter from URL
|
||||
setSearchParams({});
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// Handle clicking outside popup to close it
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
if (showItemsPopup && !target.closest('.items-popup') && !target.closest('.items-cell')) {
|
||||
setShowItemsPopup(false);
|
||||
setHoveredEvent(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (showItemsPopup) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [showItemsPopup]);
|
||||
|
||||
const fetchEvents = async () => {
|
||||
try {
|
||||
@ -53,6 +85,78 @@ const ShoppingEventList: React.FC = () => {
|
||||
setDeletingEvent(null);
|
||||
};
|
||||
|
||||
const handleEdit = (event: ShoppingEvent) => {
|
||||
setEditingEvent(event);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleEventAdded = () => {
|
||||
fetchEvents(); // Refresh the events list
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingEvent(null);
|
||||
};
|
||||
|
||||
const handleItemsHover = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
|
||||
if (event.products.length === 0) return;
|
||||
|
||||
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||
const popupWidth = 384; // max-w-md is approximately 384px
|
||||
const popupHeight = 300; // max height we set
|
||||
|
||||
let x = mouseEvent.clientX + 10;
|
||||
let y = mouseEvent.clientY - 10;
|
||||
|
||||
// Adjust if popup would go off screen
|
||||
if (x + popupWidth > window.innerWidth) {
|
||||
x = mouseEvent.clientX - popupWidth - 10;
|
||||
}
|
||||
if (y + popupHeight > window.innerHeight) {
|
||||
y = mouseEvent.clientY - popupHeight + 10;
|
||||
}
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
setHoveredEvent(event);
|
||||
setPopupPosition({ x, y });
|
||||
setShowItemsPopup(true);
|
||||
};
|
||||
|
||||
const handleItemsLeave = () => {
|
||||
setShowItemsPopup(false);
|
||||
setHoveredEvent(null);
|
||||
};
|
||||
|
||||
const handleItemsClick = (event: ShoppingEvent, mouseEvent: React.MouseEvent) => {
|
||||
if (event.products.length === 0) return;
|
||||
|
||||
mouseEvent.stopPropagation();
|
||||
const rect = mouseEvent.currentTarget.getBoundingClientRect();
|
||||
const popupWidth = 384; // max-w-md is approximately 384px
|
||||
const popupHeight = 300; // max height we set
|
||||
|
||||
let x = mouseEvent.clientX + 10;
|
||||
let y = mouseEvent.clientY - 10;
|
||||
|
||||
// Adjust if popup would go off screen
|
||||
if (x + popupWidth > window.innerWidth) {
|
||||
x = mouseEvent.clientX - popupWidth - 10;
|
||||
}
|
||||
if (y + popupHeight > window.innerHeight) {
|
||||
y = mouseEvent.clientY - popupHeight + 10;
|
||||
}
|
||||
if (y < 0) {
|
||||
y = 10;
|
||||
}
|
||||
|
||||
setHoveredEvent(event);
|
||||
setPopupPosition({ x, y });
|
||||
setShowItemsPopup(true);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
@ -66,7 +170,7 @@ const ShoppingEventList: React.FC = () => {
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Shopping Events</h1>
|
||||
<button
|
||||
onClick={() => navigate('/shopping-events/new')}
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Event
|
||||
@ -89,67 +193,79 @@ const ShoppingEventList: React.FC = () => {
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by recording your first purchase.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 p-6">
|
||||
{events.map((event) => (
|
||||
<div key={event.id} className="border border-gray-200 rounded-lg p-6 hover:shadow-md transition-shadow">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900">{event.shop.name}</h3>
|
||||
<p className="text-sm text-gray-600">{event.shop.city}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{new Date(event.date).toLocaleDateString()}
|
||||
</p>
|
||||
{event.total_amount && (
|
||||
<p className="text-lg font-semibold text-green-600">
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Shop
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Items
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Total
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Notes
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{events.map((event) => (
|
||||
<tr key={event.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">{event.shop.name}</div>
|
||||
<div className="text-xs text-gray-500">{event.shop.city}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{new Date(event.date).toLocaleDateString()}
|
||||
</td>
|
||||
<td
|
||||
className={`items-cell px-6 py-4 whitespace-nowrap text-sm ${
|
||||
event.products.length > 0
|
||||
? 'text-blue-600 hover:text-blue-800 cursor-pointer hover:bg-blue-50'
|
||||
: 'text-gray-900'
|
||||
}`}
|
||||
onMouseEnter={(e) => handleItemsHover(event, e)}
|
||||
onMouseLeave={handleItemsLeave}
|
||||
onClick={(e) => handleItemsClick(event, e)}
|
||||
title={event.products.length > 0 ? 'Click to view items' : ''}
|
||||
>
|
||||
{event.products.length} item{event.products.length !== 1 ? 's' : ''}
|
||||
{event.products.length > 0 && (
|
||||
<svg className="inline-block w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{event.total_amount ? (
|
||||
<span className="text-sm font-semibold text-green-600">
|
||||
${event.total_amount.toFixed(2)}
|
||||
</p>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-gray-500">-</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.products.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{event.products.map((product) => (
|
||||
<div key={product.id} className="bg-gray-50 rounded px-3 py-2">
|
||||
<div className="text-sm text-gray-900">
|
||||
{product.name} {product.organic ? '🌱' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{product.amount} × ${product.price.toFixed(2)} = ${(product.amount * product.price).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{event.notes && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-1">Notes:</h4>
|
||||
<p className="text-sm text-gray-600">{event.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<div className="text-gray-500">
|
||||
<div>Event #{event.id} • Created {new Date(event.created_at).toLocaleDateString()}</div>
|
||||
{event.updated_at && (
|
||||
<div className="flex items-center mt-1">
|
||||
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
Updated {new Date(event.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{event.notes ? (
|
||||
<span className="truncate max-w-xs block" title={event.notes}>
|
||||
{event.notes.length > 30 ? `${event.notes.substring(0, 30)}...` : event.notes}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/shopping-events/${event.id}/edit`)}
|
||||
className="text-indigo-600 hover:text-indigo-900"
|
||||
onClick={() => handleEdit(event)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@ -159,14 +275,21 @@ const ShoppingEventList: React.FC = () => {
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddShoppingEventModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onEventAdded={handleEventAdded}
|
||||
editEvent={editingEvent}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={!!deletingEvent}
|
||||
onClose={handleCloseDeleteModal}
|
||||
@ -175,6 +298,54 @@ const ShoppingEventList: React.FC = () => {
|
||||
message={`Are you sure you want to delete this shopping event from ${deletingEvent?.shop.name}? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
|
||||
{/* Items Popup */}
|
||||
{showItemsPopup && hoveredEvent && hoveredEvent.products.length > 0 && (
|
||||
<div
|
||||
className="items-popup fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-md"
|
||||
style={{
|
||||
left: `${popupPosition.x + 10}px`,
|
||||
top: `${popupPosition.y - 10}px`,
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto'
|
||||
}}
|
||||
onMouseEnter={() => setShowItemsPopup(true)}
|
||||
onMouseLeave={handleItemsLeave}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
{hoveredEvent.products.map((product, index) => (
|
||||
<div key={index} className="border-b border-gray-100 pb-2 last:border-b-0">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{product.name} {product.organic ? '🌱' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{product.grocery?.category?.name || 'Unknown category'}
|
||||
</div>
|
||||
{product.brand && (
|
||||
<div className="text-xs text-gray-500">
|
||||
Brand: {product.brand.name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right ml-2">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
${product.price.toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Qty: {product.amount}
|
||||
</div>
|
||||
<div className="text-xs font-medium text-green-600">
|
||||
${(product.amount * product.price).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user