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 {