brands-in-shops feature implemented

This commit is contained in:
2025-05-27 23:41:04 +02:00
parent 7037be370e
commit 2846bcbb1c
9 changed files with 559 additions and 36 deletions

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect } from 'react';
import { shopApi } from '../services/api';
import { Shop } from '../types';
import { shopApi, brandApi, brandInShopApi } from '../services/api';
import { Shop, Brand, BrandInShop } from '../types';
interface AddShopModalProps {
isOpen: boolean;
@@ -13,32 +13,70 @@ interface ShopFormData {
name: string;
city: string;
address?: string;
selectedBrands: number[];
}
const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdded, editShop }) => {
const [formData, setFormData] = useState<ShopFormData>({
name: '',
city: '',
address: ''
address: '',
selectedBrands: []
});
const [brands, setBrands] = useState<Brand[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const isEditMode = !!editShop;
// Load brands when modal opens
useEffect(() => {
if (isOpen) {
fetchBrands();
if (editShop) {
loadShopBrands(editShop.id);
}
}
}, [isOpen, editShop]);
const fetchBrands = async () => {
try {
const response = await brandApi.getAll();
setBrands(response.data);
} catch (err) {
console.error('Error fetching brands:', err);
setError('Failed to load brands. Please try again.');
}
};
const loadShopBrands = async (shopId: number) => {
try {
const response = await brandInShopApi.getByShop(shopId);
const brandIds = response.data.map((brandInShop: BrandInShop) => brandInShop.brand_id);
setFormData(prev => ({
...prev,
selectedBrands: brandIds
}));
} catch (err) {
console.error('Error loading shop brands:', err);
}
};
// Initialize form data when editing
useEffect(() => {
if (editShop) {
setFormData({
setFormData(prev => ({
...prev,
name: editShop.name,
city: editShop.city,
address: editShop.address || ''
});
}));
} else {
setFormData({
name: '',
city: '',
address: ''
address: '',
selectedBrands: []
});
}
setError('');
@@ -86,17 +124,48 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
address: trimmedAddress && trimmedAddress.length > 0 ? trimmedAddress : null
};
let shopId: number;
if (isEditMode && editShop) {
await shopApi.update(editShop.id, shopData);
const updatedShop = await shopApi.update(editShop.id, shopData);
shopId = editShop.id;
} else {
await shopApi.create(shopData);
const newShop = await shopApi.create(shopData);
shopId = newShop.data.id;
}
// Handle brand associations
if (isEditMode && editShop) {
// Get existing brand associations
const existingBrands = await brandInShopApi.getByShop(editShop.id);
const existingBrandIds = existingBrands.data.map(b => b.brand_id);
// Remove brands that are no longer selected
for (const brandInShop of existingBrands.data) {
if (!formData.selectedBrands.includes(brandInShop.brand_id)) {
await brandInShopApi.delete(brandInShop.id);
}
}
// Add new brand associations
for (const brandId of formData.selectedBrands) {
if (!existingBrandIds.includes(brandId)) {
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
}
}
} else {
// Create new brand associations for new shop
for (const brandId of formData.selectedBrands) {
await brandInShopApi.create({ shop_id: shopId, brand_id: brandId });
}
}
// Reset form
setFormData({
name: '',
city: '',
address: ''
address: '',
selectedBrands: []
});
onShopAdded();
@@ -117,11 +186,20 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
}));
};
const handleBrandToggle = (brandId: number) => {
setFormData(prev => ({
...prev,
selectedBrands: prev.selectedBrands.includes(brandId)
? prev.selectedBrands.filter(id => id !== brandId)
: [...prev.selectedBrands, brandId]
}));
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white">
<div className="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white max-h-[80vh] overflow-y-auto">
<div className="mt-3">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900">
@@ -191,6 +269,34 @@ const AddShopModal: React.FC<AddShopModalProps> = ({ isOpen, onClose, onShopAdde
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Available Brands (Optional)
</label>
<div className="max-h-40 overflow-y-auto border border-gray-300 rounded-md p-3 bg-gray-50">
{brands.length === 0 ? (
<p className="text-sm text-gray-500">Loading brands...</p>
) : (
<div className="space-y-2">
{brands.map(brand => (
<label key={brand.id} className="flex items-center">
<input
type="checkbox"
checked={formData.selectedBrands.includes(brand.id)}
onChange={() => handleBrandToggle(brand.id)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-900">{brand.name}</span>
</label>
))}
</div>
)}
</div>
<p className="mt-1 text-xs text-gray-500">
Select the brands that are available in this shop
</p>
</div>
<div className="flex justify-end space-x-3 pt-4">
<button
type="button"

View File

@@ -1,6 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent } from '../types';
import { shopApi, productApi, shoppingEventApi } from '../services/api';
import { Shop, Product, ShoppingEventCreate, ProductInEvent, ShoppingEvent, BrandInShop } from '../types';
import { shopApi, productApi, shoppingEventApi, brandInShopApi } from '../services/api';
interface AddShoppingEventModalProps {
isOpen: boolean;
@@ -17,6 +17,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
}) => {
const [shops, setShops] = useState<Shop[]>([]);
const [products, setProducts] = useState<Product[]>([]);
const [shopBrands, setShopBrands] = useState<BrandInShop[]>([]);
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
@@ -160,6 +161,30 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
}
};
const fetchShopBrands = async (shopId: number) => {
if (shopId === 0) {
setShopBrands([]);
return;
}
try {
const response = await brandInShopApi.getByShop(shopId);
setShopBrands(response.data);
} catch (error) {
console.error('Error fetching shop brands:', error);
setShopBrands([]);
}
};
// Effect to load shop brands when shop selection changes
useEffect(() => {
if (formData.shop_id > 0) {
fetchShopBrands(formData.shop_id);
} else {
setShopBrands([]);
}
}, [formData.shop_id]);
const addProductToEvent = () => {
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
@@ -224,6 +249,23 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
return `${product.name}${organicEmoji} ${weightInfo}`;
};
// Filter products based on selected shop's brands
const getFilteredProducts = () => {
// If no shop is selected or shop has no brands, show all products
if (formData.shop_id === 0 || shopBrands.length === 0) {
return products;
}
// Get brand IDs available in the selected shop
const availableBrandIds = shopBrands.map(sb => sb.brand_id);
// Filter products to only show those with brands available in the shop
// Also include products without brands (brand_id is null/undefined)
return products.filter(product =>
!product.brand_id || availableBrandIds.includes(product.brand_id)
);
};
if (!isOpen) return null;
return (
@@ -306,7 +348,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
>
<option value={0}>Select a product</option>
{Object.entries(
products.reduce((groups, product) => {
getFilteredProducts().reduce((groups, product) => {
const category = product.grocery.category.name;
if (!groups[category]) {
groups[category] = [];
@@ -329,6 +371,14 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
</optgroup>
))}
</select>
{formData.shop_id > 0 && (
<p className="text-xs text-gray-500 mt-1">
{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>
)}
</div>
<div className="w-24">
<label className="block text-xs font-medium text-gray-700 mb-1">

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Shop } from '../types';
import { shopApi } from '../services/api';
import { Shop, BrandInShop } from '../types';
import { shopApi, brandInShopApi } from '../services/api';
import AddShopModal from './AddShopModal';
import ConfirmDeleteModal from './ConfirmDeleteModal';
@@ -16,6 +16,10 @@ const ShopList: React.FC = () => {
const [deleteLoading, setDeleteLoading] = useState(false);
const [sortField, setSortField] = useState<keyof Shop>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [hoveredShop, setHoveredShop] = useState<Shop | null>(null);
const [showBrandsPopup, setShowBrandsPopup] = useState(false);
const [popupPosition, setPopupPosition] = useState({ x: 0, y: 0 });
const [shopBrands, setShopBrands] = useState<Record<number, BrandInShop[]>>({});
useEffect(() => {
fetchShops();
@@ -28,11 +32,35 @@ const ShopList: React.FC = () => {
}
}, [searchParams, setSearchParams]);
// Handle clicking outside popup to close it
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (showBrandsPopup && !target.closest('.brands-popup') && !target.closest('.brands-cell')) {
setShowBrandsPopup(false);
setHoveredShop(null);
}
};
if (showBrandsPopup) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [showBrandsPopup]);
const fetchShops = async () => {
try {
setLoading(true);
const response = await shopApi.getAll();
setShops(response.data);
// Load brands for all shops
for (const shop of response.data) {
loadShopBrands(shop.id);
}
} catch (err) {
setError('Failed to fetch shops');
console.error('Error fetching shops:', err);
@@ -41,6 +69,18 @@ const ShopList: React.FC = () => {
}
};
const loadShopBrands = async (shopId: number) => {
try {
const response = await brandInShopApi.getByShop(shopId);
setShopBrands(prev => ({
...prev,
[shopId]: response.data
}));
} catch (err) {
console.error('Error loading shop brands:', err);
}
};
const handleShopAdded = () => {
fetchShops(); // Refresh the shops list
};
@@ -79,6 +119,66 @@ const ShopList: React.FC = () => {
setDeletingShop(null);
};
const handleBrandsHover = (shop: Shop, mouseEvent: React.MouseEvent) => {
const brands = shopBrands[shop.id] || [];
if (brands.length === 0) return;
const rect = mouseEvent.currentTarget.getBoundingClientRect();
const popupWidth = 300;
const popupHeight = 200;
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;
}
setHoveredShop(shop);
setPopupPosition({ x, y });
setShowBrandsPopup(true);
};
const handleBrandsLeave = () => {
setShowBrandsPopup(false);
setHoveredShop(null);
};
const handleBrandsClick = (shop: Shop, mouseEvent: React.MouseEvent) => {
const brands = shopBrands[shop.id] || [];
if (brands.length === 0) return;
mouseEvent.stopPropagation();
const rect = mouseEvent.currentTarget.getBoundingClientRect();
const popupWidth = 300;
const popupHeight = 200;
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;
}
setHoveredShop(shop);
setPopupPosition({ x, y });
setShowBrandsPopup(true);
};
const handleSort = (field: keyof Shop) => {
if (field === sortField) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
@@ -197,6 +297,9 @@ const ShopList: React.FC = () => {
{getSortIcon('address')}
</div>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Brands
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100 select-none"
onClick={() => handleSort('created_at')}
@@ -225,6 +328,28 @@ const ShopList: React.FC = () => {
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{shop.address || '-'}
</td>
<td
className={`brands-cell px-6 py-4 whitespace-nowrap text-sm ${
(shopBrands[shop.id]?.length || 0) > 0
? 'text-blue-600 hover:text-blue-800 cursor-pointer hover:bg-blue-50'
: 'text-gray-900'
}`}
onMouseEnter={(e) => handleBrandsHover(shop, e)}
onMouseLeave={handleBrandsLeave}
onClick={(e) => handleBrandsClick(shop, e)}
title={(shopBrands[shop.id]?.length || 0) > 0 ? 'Click to view brands' : ''}
>
{(shopBrands[shop.id]?.length || 0) > 0 ? (
<>
{(shopBrands[shop.id]?.length || 0)} brand{(shopBrands[shop.id]?.length || 0) !== 1 ? 's' : ''}
<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 text-sm text-gray-500">
{new Date(shop.created_at).toLocaleDateString()}
</td>
@@ -264,6 +389,32 @@ const ShopList: React.FC = () => {
message={`Are you sure you want to delete "${deletingShop?.name}"? This action cannot be undone.`}
isLoading={deleteLoading}
/>
{/* Brands Popup */}
{showBrandsPopup && hoveredShop && (shopBrands[hoveredShop.id]?.length || 0) > 0 && (
<div
className="brands-popup fixed z-50 bg-white border border-gray-200 rounded-lg shadow-lg p-4 max-w-sm"
style={{
left: `${popupPosition.x}px`,
top: `${popupPosition.y}px`,
maxHeight: '200px',
overflowY: 'auto'
}}
onMouseEnter={() => setShowBrandsPopup(true)}
onMouseLeave={handleBrandsLeave}
>
<div className="space-y-2">
<h4 className="font-medium text-gray-900 mb-2">Available Brands:</h4>
{shopBrands[hoveredShop.id]?.map((brandInShop, index) => (
<div key={index} className="border-b border-gray-100 pb-1 last:border-b-0">
<div className="text-sm font-medium text-gray-900">
{brandInShop.brand.name}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
};