Minor version bump (1.x.0) is appropriate because:

✅ New functionality added (soft delete system)
✅ Backward compatible (existing features unchanged)
✅ Significant enhancement (complete temporal tracking system)
✅ API additions (new endpoints, parameters)
✅ UI enhancements (new components, visual indicators)
This commit is contained in:
2025-05-30 09:49:26 +02:00
parent 56c3c16f6d
commit 0b42a74fe9
16 changed files with 1438 additions and 237 deletions

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { productApi, brandApi, groceryCategoryApi } from '../services/api';
import { productApi, brandApi, groceryCategoryApi, utilityApi } from '../services/api';
import { Product, Brand, GroceryCategory } from '../types';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
@@ -18,6 +18,7 @@ interface ProductFormData {
organic: boolean;
weight?: number;
weight_unit: string;
valid_from: string; // ISO date string (YYYY-MM-DD)
}
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct, duplicateProduct }) => {
@@ -27,12 +28,15 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
weight_unit: 'piece',
valid_from: ''
});
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<GroceryCategory[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [currentDate, setCurrentDate] = useState('');
const [minValidFromDate, setMinValidFromDate] = useState('');
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
@@ -44,6 +48,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
if (isOpen) {
fetchBrands();
fetchCategories();
fetchCurrentDate();
}
}, [isOpen]);
@@ -65,52 +70,113 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
}
};
const fetchCurrentDate = async () => {
try {
const response = await utilityApi.getCurrentDate();
// Only update if valid_from is not already set
setFormData(prev => ({
...prev,
valid_from: prev.valid_from || response.data.current_date
}));
setCurrentDate(response.data.current_date);
setMinValidFromDate(response.data.current_date);
} catch (err) {
console.error('Failed to fetch current date:', err);
// Fallback to current date if API fails
const today = new Date().toISOString().split('T')[0];
setFormData(prev => ({
...prev,
valid_from: prev.valid_from || today
}));
setCurrentDate(today);
setMinValidFromDate(today);
}
};
// Populate form when editing or duplicating
useEffect(() => {
if (editProduct) {
if (editProduct && isOpen) {
// For editing, fetch the current valid_from to set proper constraints
const fetchProductValidFrom = async () => {
try {
const response = await productApi.getValidFromDate(editProduct.id);
const currentValidFrom = response.data.valid_from;
setMinValidFromDate(currentValidFrom);
setFormData({
name: editProduct.name,
category_id: editProduct.category_id,
brand_id: editProduct.brand_id,
organic: editProduct.organic,
weight: editProduct.weight,
weight_unit: editProduct.weight_unit,
valid_from: currentDate // Default to today for edits
});
} catch (err) {
console.error('Failed to fetch product valid_from:', err);
setError('Failed to load product data for editing');
}
};
if (currentDate) {
fetchProductValidFrom();
}
} else if (duplicateProduct && isOpen) {
// For duplicating, use today as default and allow any date <= today
setMinValidFromDate('1900-01-01'); // No restriction for new products
setFormData({
name: editProduct.name,
category_id: editProduct.category_id,
brand_id: editProduct.brand_id,
organic: editProduct.organic,
weight: editProduct.weight,
weight_unit: editProduct.weight_unit
});
} else if (duplicateProduct) {
setFormData({
name: duplicateProduct.name,
name: `${duplicateProduct.name} (Copy)`,
category_id: duplicateProduct.category_id,
brand_id: duplicateProduct.brand_id,
organic: duplicateProduct.organic,
weight: duplicateProduct.weight,
weight_unit: duplicateProduct.weight_unit
weight_unit: duplicateProduct.weight_unit,
valid_from: currentDate
});
} else {
// Reset form for adding new product
} else if (isOpen && currentDate) {
// For new products, allow any date <= today
setMinValidFromDate('1900-01-01'); // No restriction for new products
setFormData({
name: '',
category_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
weight_unit: 'piece',
valid_from: currentDate
});
}
setError('');
}, [editProduct, duplicateProduct, isOpen]);
}, [editProduct, duplicateProduct, isOpen, currentDate]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.category_id) {
if (!formData.name.trim() || !formData.category_id || !formData.valid_from) {
setError('Please fill in all required fields with valid values');
return;
}
// Validate date constraints
const validFromDate = new Date(formData.valid_from);
const today = new Date(currentDate);
if (validFromDate > today) {
setError('Valid from date cannot be in the future');
return;
}
if (editProduct) {
const minDate = new Date(minValidFromDate);
if (validFromDate <= minDate) {
setError(`Valid from date must be after the current product's valid from date (${minValidFromDate})`);
return;
}
}
try {
setLoading(true);
setError('');
const productData = {
const productData: any = {
name: formData.name.trim(),
category_id: formData.category_id!,
brand_id: formData.brand_id || undefined,
@@ -118,6 +184,11 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
weight: formData.weight || undefined,
weight_unit: formData.weight_unit
};
// Only include valid_from if it's provided
if (formData.valid_from) {
productData.valid_from = formData.valid_from;
}
if (editProduct) {
// Update existing product
@@ -134,7 +205,8 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
weight_unit: 'piece',
valid_from: ''
});
onProductAdded();
@@ -145,7 +217,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
} finally {
setLoading(false);
}
}, [formData, editProduct, onProductAdded, onClose]);
}, [formData, editProduct, onProductAdded, onClose, currentDate, minValidFromDate]);
useEffect(() => {
if (!isOpen) return;
@@ -228,10 +300,37 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Whole Foods Organic Milk"
placeholder="Product name"
/>
</div>
<div>
<label htmlFor="valid_from" className="block text-sm font-medium text-gray-700">
Valid from *
</label>
<input
type="date"
id="valid_from"
name="valid_from"
value={formData.valid_from}
onChange={handleChange}
required
min={editProduct ? (() => {
const nextDay = new Date(minValidFromDate);
nextDay.setDate(nextDay.getDate() + 1);
return nextDay.toISOString().split('T')[0];
})() : undefined}
max={currentDate}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">
{editProduct
? `Must be after ${minValidFromDate} and not in the future`
: 'The date when this product information becomes effective (cannot be in the future)'
}
</p>
</div>
<div>
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">
Category *

View File

@@ -190,7 +190,11 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
const fetchProducts = async () => {
try {
const response = await productApi.getAll();
// If we have a shopping date, get products available for that date
// Otherwise, get all non-deleted products
const response = formData.date
? await productApi.getAvailableForShopping(formData.date)
: await productApi.getAll(false); // false = don't show deleted
setProducts(response.data);
} catch (error) {
console.error('Error fetching products:', error);
@@ -223,6 +227,13 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
}
}, [formData.shop_id]);
// Effect to refetch products when shopping date changes
useEffect(() => {
if (isOpen && formData.date) {
fetchProducts();
}
}, [formData.date, isOpen]);
const addProductToEvent = () => {
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
@@ -483,7 +494,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
))}
</select>
</div>
<div className="w-24">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Amount
</label>
@@ -494,10 +505,10 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
placeholder="1"
value={newProductItem.amount}
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
className="w-full h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-24 h-10 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">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Price ($)
</label>
@@ -508,165 +519,133 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
placeholder="0.00"
value={newProductItem.price}
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
className="w-full h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-24 h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="w-20">
<label className="block text-xs font-medium text-gray-700 mb-1 text-center">
Discount
</label>
<div className="h-10 flex items-center justify-center border border-gray-300 rounded-md bg-gray-50">
<div className="flex items-center">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={newProductItem.discount}
onChange={(e) => setNewProductItem({...newProductItem, discount: e.target.checked})}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
</div>
<div className="w-16">
<label className="block text-xs font-medium text-gray-700 mb-1 opacity-0">
Action
<span className="text-xs font-medium text-gray-700">Discount</span>
</label>
</div>
<div className="flex items-end">
<button
type="button"
onClick={addProductToEvent}
className="w-full h-10 bg-green-500 hover:bg-green-700 text-white px-3 py-2 rounded-md font-medium"
className="px-6 py-2 bg-green-500 hover:bg-green-700 text-white rounded-md font-medium text-sm"
>
Add
Add Product
</button>
</div>
</div>
{formData.shop_id > 0 && (
<p className="text-xs text-gray-500 mb-4">
{shopBrands.length === 0
? `Showing all ${products.length} products (no brand restrictions for this shop)`
: `Showing ${getFilteredProducts().length} of ${products.length} products (filtered by shop's available brands)`
}
</p>
)}
{/* Selected Products List */}
{selectedProducts.length > 0 && (
<div className="bg-gray-50 rounded-md p-4 max-h-40 md:max-h-48 overflow-y-auto">
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
{Object.entries(
selectedProducts.reduce((groups, item, index) => {
const product = products.find(p => p.id === item.product_id);
const category = product?.category.name || 'Unknown';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push({ ...item, index });
return groups;
}, {} as Record<string, (ProductInEvent & { index: number })[]>)
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, categoryItems]) => (
<div key={category} className="mb-3 last:mb-0">
<div className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-1 border-b border-gray-300 pb-1">
{category}
</div>
{categoryItems.map((item) => (
<div key={item.index} className="flex flex-col md:flex-row md:justify-between md:items-center py-2 pl-2 space-y-2 md:space-y-0">
<div className="flex-1 min-w-0">
<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)}
{item.discount && <span className="ml-2 text-green-600 font-medium">🏷️</span>}
</div>
</div>
<div className="flex space-x-2 md:flex-shrink-0">
<button
type="button"
onClick={() => editProductFromEvent(item.index)}
className="flex-1 md:flex-none px-3 py-1 text-blue-500 hover:text-blue-700 border border-blue-300 hover:bg-blue-50 rounded text-sm"
>
Edit
</button>
<button
type="button"
onClick={() => removeProductFromEvent(item.index)}
className="flex-1 md:flex-none px-3 py-1 text-red-500 hover:text-red-700 border border-red-300 hover:bg-red-50 rounded text-sm"
>
Remove
</button>
</div>
</div>
))}
</div>
))}
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price ($)
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Discount
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total ($)
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{selectedProducts.map((product, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{getProductName(product.product_id)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.amount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.price.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.discount ? 'Yes' : 'No'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(product.amount * product.price).toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
type="button"
onClick={() => editProductFromEvent(index)}
className="text-indigo-600 hover:text-indigo-900 mr-2"
>
Edit
</button>
<button
type="button"
onClick={() => removeProductFromEvent(index)}
className="text-red-600 hover:text-red-900"
>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Total Amount */}
<div>
<div className="flex items-center space-x-2 mb-2">
<label className="block text-sm font-medium text-gray-700">
{/* Total Amount and Notes */}
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Amount ($)
</label>
<label className="flex items-center space-x-1">
<input
type="checkbox"
checked={autoCalculate}
onChange={(e) => setAutoCalculate(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-xs text-gray-600">Auto-calculate</span>
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.total_amount}
onChange={(e) => setFormData({...formData, total_amount: parseFloat(e.target.value)})}
className="w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: e.target.value})}
className="w-full h-24 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</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})}
disabled={autoCalculate}
className={`w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
autoCalculate ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
/>
{autoCalculate && selectedProducts.length > 0 && (
<p className="text-xs text-gray-500 mt-1">
Automatically calculated from selected products: ${calculateTotal(selectedProducts).toFixed(2)}
</p>
)}
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes (Optional)
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: e.target.value})}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add any notes about this shopping event..."
/>
</div>
{/* Submit Button */}
<div className="flex flex-col md:flex-row md:justify-end space-y-3 md:space-y-0 md:space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="w-full md:w-auto px-6 py-3 md:py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 font-medium text-base md:text-sm"
>
Cancel
</button>
{/* Save Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
className="w-full md:w-auto px-6 py-3 md:py-2 bg-blue-500 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-md font-medium text-base md:text-sm"
disabled={loading}
className="px-6 py-3 bg-blue-500 hover:bg-blue-700 text-white rounded-md font-medium text-base disabled:opacity-50"
>
{loading ? 'Saving...' : (isEditMode ? 'Update Event' : 'Create Event')}
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</form>
@@ -676,4 +655,4 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
);
};
export default AddShoppingEventModal;
export default AddShoppingEventModal;

View File

@@ -17,6 +17,7 @@ const ProductList: React.FC = () => {
const [duplicatingProduct, setDuplicatingProduct] = useState<Product | null>(null);
const [sortField, setSortField] = useState<string>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [showDeleted, setShowDeleted] = useState(false);
useEffect(() => {
fetchProducts();
@@ -27,12 +28,12 @@ const ProductList: React.FC = () => {
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
}, [searchParams, setSearchParams, showDeleted]);
const fetchProducts = async () => {
try {
setLoading(true);
const response = await productApi.getAll();
const response = await productApi.getAll(showDeleted);
setProducts(response.data);
} catch (err) {
setError('Failed to fetch products');
@@ -180,16 +181,27 @@ const ProductList: React.FC = () => {
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Products</h1>
<button
onClick={() => {
setEditingProduct(null);
setDuplicatingProduct(null);
setIsModalOpen(true);
}}
className="w-full sm:w-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 sm:py-2 px-4 rounded text-base sm:text-sm"
>
Add New Product
</button>
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={showDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Show deleted</span>
</label>
<button
onClick={() => {
setEditingProduct(null);
setDuplicatingProduct(null);
setIsModalOpen(true);
}}
className="w-full sm:w-auto bg-blue-500 hover:bg-blue-700 text-white font-bold py-3 sm:py-2 px-4 rounded text-base sm:text-sm"
>
Add New Product
</button>
</div>
</div>
{error && (
@@ -257,40 +269,46 @@ const ProductList: React.FC = () => {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedProducts.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<tr key={product.id} className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{product.name} {product.organic ? '🌱' : ''}
<div className={`text-sm font-medium ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.category.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.brand ? product.brand.name : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button
onClick={() => handleEdit(product)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDuplicate(product)}
className="text-green-600 hover:text-green-900 mr-3"
>
Duplicate
</button>
<button
onClick={() => handleDelete(product)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
{!product.deleted ? (
<>
<button
onClick={() => handleEdit(product)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
>
Edit
</button>
<button
onClick={() => handleDuplicate(product)}
className="text-green-600 hover:text-green-900 mr-3"
>
Duplicate
</button>
<button
onClick={() => handleDelete(product)}
className="text-red-600 hover:text-red-900"
>
Delete
</button>
</>
) : (
<span className="text-gray-400 text-sm">Deleted</span>
)}
</td>
</tr>
))}
@@ -301,49 +319,55 @@ const ProductList: React.FC = () => {
{/* Mobile Card Layout */}
<div className="md:hidden">
{sortedProducts.map((product) => (
<div key={product.id} className="border-b border-gray-200 p-4 last:border-b-0">
<div key={product.id} className={`border-b border-gray-200 p-4 last:border-b-0 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">
{product.name} {product.organic ? '🌱' : ''}
<h3 className={`font-medium truncate ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
</h3>
<p className="text-sm text-gray-500">{product.category.name}</p>
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-500'}`}>{product.category.name}</p>
</div>
{product.weight && (
<div className="text-right flex-shrink-0 ml-4">
<p className="text-sm text-gray-600">{product.weight}{product.weight_unit}</p>
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>{product.weight}{product.weight_unit}</p>
</div>
)}
</div>
{product.brand && (
<div className="mb-3">
<p className="text-sm text-gray-600">
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>
<span className="font-medium">Brand:</span> {product.brand.name}
</p>
</div>
)}
<div className="flex space-x-4">
<button
onClick={() => handleEdit(product)}
className="flex-1 text-center py-2 px-4 border border-indigo-300 text-indigo-600 hover:bg-indigo-50 rounded-md text-sm font-medium"
>
Edit
</button>
<button
onClick={() => handleDuplicate(product)}
className="flex-1 text-center py-2 px-4 border border-green-300 text-green-600 hover:bg-green-50 rounded-md text-sm font-medium"
>
Duplicate
</button>
<button
onClick={() => handleDelete(product)}
className="flex-1 text-center py-2 px-4 border border-red-300 text-red-600 hover:bg-red-50 rounded-md text-sm font-medium"
>
Delete
</button>
</div>
{!product.deleted ? (
<div className="flex space-x-4">
<button
onClick={() => handleEdit(product)}
className="flex-1 text-center py-2 px-4 border border-indigo-300 text-indigo-600 hover:bg-indigo-50 rounded-md text-sm font-medium"
>
Edit
</button>
<button
onClick={() => handleDuplicate(product)}
className="flex-1 text-center py-2 px-4 border border-green-300 text-green-600 hover:bg-green-50 rounded-md text-sm font-medium"
>
Duplicate
</button>
<button
onClick={() => handleDelete(product)}
className="flex-1 text-center py-2 px-4 border border-red-300 text-red-600 hover:bg-red-50 rounded-md text-sm font-medium"
>
Delete
</button>
</div>
) : (
<div className="text-center py-2">
<span className="text-gray-400 text-sm">Deleted</span>
</div>
)}
</div>
))}
</div>

View File

@@ -26,8 +26,10 @@ const api = {
// Product API functions
export const productApi = {
getAll: () => api.get<Product[]>('/products/'),
getAll: (showDeleted: boolean = false) => api.get<Product[]>(`/products/?show_deleted=${showDeleted}`),
getById: (id: number) => api.get<Product>(`/products/${id}`),
getValidFromDate: (id: number) => api.get<{ valid_from: string }>(`/products/${id}/valid-from`),
getAvailableForShopping: (shoppingDate: string) => api.get<Product[]>(`/products/available-for-shopping/${shoppingDate}`),
create: (product: ProductCreate) => api.post<Product>('/products/', product),
update: (id: number, product: Partial<ProductCreate>) =>
api.put<Product>(`/products/${id}`, product),
@@ -89,4 +91,9 @@ export const statsApi = {
getShops: () => api.get('/stats/shops'),
};
// Utility API functions
export const utilityApi = {
getCurrentDate: () => api.get<{ current_date: string }>('/current-date'),
};
export default api;

View File

@@ -30,6 +30,7 @@ export interface Product {
organic: boolean;
weight?: number;
weight_unit: string;
deleted?: boolean;
created_at: string;
updated_at?: string;
}
@@ -41,6 +42,7 @@ export interface ProductCreate {
organic: boolean;
weight?: number;
weight_unit: string;
valid_from?: string; // Optional: ISO date string (YYYY-MM-DD), defaults to current date if not provided
}
export interface Shop {