Fix modal persistence issue

- Add duplicate prevention with isProcessing state
- Implement loading spinner and disabled states
- Enhance modal closure logic in DataImport.tsx
- Add proper state reset on modal close
- Prevent multiple button clicks during processing
- Improve user feedback with console logging
This commit is contained in:
dlawler489 2026-05-01 15:31:26 +10:00
parent 461e424e5e
commit f39d4ca266
3 changed files with 600 additions and 91 deletions

View file

@ -1,7 +1,9 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import React, { useState, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addProduct } from '../store/slices/productSlice';
import { X, Plus } from 'lucide-react';
import { findPotentialMatches } from '../utils/productMatcher';
import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../utils/api';
@ -25,26 +27,86 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
onComplete
}) => {
const dispatch = useDispatch();
const products = useSelector((state: RootState) => state.products.products);
const [isProcessing, setIsProcessing] = useState(false);
const [productData, setProductData] = useState<{[key: string]: {
printingCost: number;
category: string;
useExisting?: string; // ID of existing product to use instead of creating new
}}>({});
// Find potential matches for each missing product
const suggestions = useMemo(() => {
const result: {[key: string]: ReturnType<typeof findPotentialMatches>} = {};
missingProducts.forEach(missingProduct => {
result[missingProduct.title] = findPotentialMatches(
missingProduct.title,
products,
3 // Show top 3 suggestions
);
});
return result;
}, [missingProducts, products]);
const handleInputChange = (productTitle: string, field: string, value: string) => {
setProductData(prev => ({
...prev,
[productTitle]: {
...prev[productTitle],
[field]: field === 'category' ? value : parseFloat(value) || 0
[field]: field === 'category' ? value :
field === 'useExisting' ? value :
parseFloat(value) || 0
}
}));
};
const handleUseExisting = (missingTitle: string, existingProductId: string) => {
setProductData(prev => ({
...prev,
[missingTitle]: {
...prev[missingTitle],
useExisting: existingProductId
}
}));
};
const handleAddProducts = async () => {
// Prevent multiple clicks while processing
if (isProcessing) {
console.log('Already processing, ignoring click');
return;
}
setIsProcessing(true);
console.log('Starting product creation process...');
try {
const newProducts: any[] = [];
const existingMatches: any[] = [];
for (const product of missingProducts) {
const data = productData[product.title] || {};
// If user selected to use existing product, add it to matches
if (data.useExisting) {
const existingProduct = products.find(p => p._id === data.useExisting);
if (existingProduct) {
existingMatches.push({
orderItem: {
title: product.title,
quantity: product.quantity,
price: product.price
},
matchedProduct: existingProduct
});
continue;
}
}
// Otherwise create new product
const printingCost = data.printingCost || 0;
const category = data.category || 'Imported Items';
@ -63,7 +125,8 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
});
dispatch(addProduct(res.data));
newProducts.push(res.data);
} catch {
} catch (error) {
console.error('Error creating product:', product.title, error);
// Fall back to local-only if API fails
const fallback = {
_id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
@ -83,8 +146,24 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
}
}
if (newProducts.length > 0) {
toast.success(`Added ${newProducts.length} new products with printing costs`);
onComplete(newProducts);
}
if (existingMatches.length > 0) {
toast.success(`Matched ${existingMatches.length} items to existing products`);
}
console.log('Products processed successfully, calling onComplete...');
// Call onComplete and let parent handle closing
onComplete([...newProducts, ...existingMatches]);
} catch (error) {
console.error('Error processing products:', error);
toast.error('Failed to process products. Please try again.');
} finally {
setIsProcessing(false);
}
};
return (
@ -97,22 +176,27 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
disabled={isProcessing}
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6">
<p className="text-gray-600 mb-4">
The following products from your order are not in your product database.
Please add printing costs (including materials/filament) to complete the import:
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p className="text-blue-900 text-sm">
<strong>Found unmatched product:</strong> "{missingProducts[0]?.title}"
</p>
<p className="text-blue-700 text-xs mt-1">
This product isn't in your database yet. Add printing costs to complete the import.
</p>
</div>
<div className="space-y-4">
{missingProducts.map((product, index) => (
<div key={index} className="border rounded-lg p-4 bg-gray-50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Product Name
</label>
@ -124,56 +208,124 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
</div>
</div>
{/* Show suggestions if available */}
{suggestions[product.title] && suggestions[product.title].length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">
Possible matches found:
</span>
</div>
<div className="space-y-2">
{suggestions[product.title].map((suggestion, sugIndex) => (
<div key={sugIndex} className="flex items-center justify-between">
<div className="flex-1">
<div className="text-sm text-gray-900">
{suggestion.product.title}
</div>
<div className="text-xs text-gray-600">
{suggestion.reason} ({(suggestion.confidence * 100).toFixed(0)}% match)
</div>
</div>
<button
onClick={() => handleUseExisting(product.title, suggestion.product._id)}
className={`px-3 py-1 text-xs rounded-md border transition-colors ${
productData[product.title]?.useExisting === suggestion.product._id
? 'bg-green-100 border-green-300 text-green-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
disabled={isProcessing}
>
{productData[product.title]?.useExisting === suggestion.product._id ? (
<span className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Selected
</span>
) : (
'Use This'
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Only show input fields if not using existing product */}
{!productData[product.title]?.useExisting && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Printing Cost per Item (inc. materials)
Printing Cost ($)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={productData[product.title]?.printingCost || ''}
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
disabled={isProcessing}
/>
<p className="text-xs text-gray-500 mt-1">
Include filament, materials, and time costs
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={productData[product.title]?.category || ''}
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
disabled={isProcessing}
>
<option value="">Select category...</option>
<option value="3D Prints">3D Prints</option>
<option value="">Select category</option>
<option value="Imported Items">Imported Items</option>
<option value="3D Printed">3D Printed</option>
<option value="Home & Living">Home & Living</option>
<option value="Art & Collectibles">Art & Collectibles</option>
<option value="Jewelry">Jewelry</option>
<option value="Home Decor">Home Decor</option>
<option value="Organizers">Organizers</option>
<option value="Planters">Planters</option>
<option value="Custom Items">Custom Items</option>
<option value="Other">Other</option>
</select>
</div>
</div>
)}
</div>
</div>
))}
</div>
<div className="flex justify-end gap-3 mt-6">
<div className="flex gap-3 mt-6 justify-end">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
className="px-4 py-2 text-gray-700 border border-gray-300 rounded-lg hover:bg-gray-50"
disabled={isProcessing}
>
Cancel
</button>
<button
onClick={handleAddProducts}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
className={`flex items-center gap-2 px-4 py-2 text-white rounded-lg transition-colors ${
isProcessing
? 'bg-gray-400 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700'
}`}
disabled={isProcessing}
>
{isProcessing ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
Processing...
</>
) : (
<>
<Plus className="w-4 h-4" />
Add Products to Database
</>
)}
</button>
</div>
</div>

View file

@ -0,0 +1,344 @@
import React, { useState, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addProduct } from '../store/slices/productSlice';
import { findPotentialMatches } from '../utils/productMatcher';
import { X, Plus, AlertCircle, CheckCircle } from 'lucide-react';
import toast from 'react-hot-toast';
import api from '../utils/api';
export interface MissingProduct {
title: string;
quantity: number;
price?: number;
size?: string;
orderNumber?: string;
}
interface MissingProductsModalProps {
missingProducts: MissingProduct[];
onClose: () => void;
onComplete: (products: any[]) => void;
}
export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
missingProducts,
onClose,
onComplete
}) => {
const dispatch = useDispatch();
const products = useSelector((state: RootState) => state.products.products);
const [isProcessing, setIsProcessing] = useState(false);
const [productData, setProductData] = useState<{[key: string]: {
printingCost: number;
category: string;
useExisting?: string; // ID of existing product to use instead of creating new
}}>({});
// Find potential matches for each missing product
const suggestions = useMemo(() => {
const result: {[key: string]: ReturnType<typeof findPotentialMatches>} = {};
missingProducts.forEach(missingProduct => {
result[missingProduct.title] = findPotentialMatches(
missingProduct.title,
products,
3 // Show top 3 suggestions
);
});
return result;
}, [missingProducts, products]);
const handleInputChange = (productTitle: string, field: string, value: string) => {
setProductData(prev => ({
...prev,
[productTitle]: {
...prev[productTitle],
[field]: field === 'category' ? value :
field === 'useExisting' ? value :
parseFloat(value) || 0
}
}));
};
const handleUseExisting = (missingTitle: string, existingProductId: string) => {
setProductData(prev => ({
...prev,
[missingTitle]: {
...prev[missingTitle],
useExisting: existingProductId
}
}));
};
const handleAddProducts = async () => {
// Prevent multiple clicks
if (isProcessing) return;
setIsProcessing(true);
try {
const newProducts: any[] = [];
const existingMatches: any[] = [];
for (const product of missingProducts) {
const data = productData[product.title] || {};
// If user selected to use existing product, add it to matches
if (data.useExisting) {
const existingProduct = products.find(p => p._id === data.useExisting);
if (existingProduct) {
existingMatches.push({
orderItem: {
title: product.title,
quantity: product.quantity,
price: product.price
},
matchedProduct: existingProduct
});
continue;
}
}
// Otherwise create new product
const printingCost = data.printingCost || 0;
const category = data.category || 'Imported Items';
try {
const res = await api.post('/products', {
title: product.title,
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
price: product.price || 0,
costOfGoods: 0,
printingCost,
sku: '',
category,
tags: [],
inventory: { quantity: 0, lowStockAlert: 5 },
isActive: true
});
dispatch(addProduct(res.data));
newProducts.push(res.data);
} catch {
// Fall back to local-only if API fails
const fallback = {
_id: `product_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
title: product.title,
description: `Imported from order ${product.orderNumber || 'Unknown'}`,
price: product.price || 0,
costOfGoods: 0,
printingCost,
sku: '',
category,
tags: [],
inventory: { quantity: 0, lowStockAlert: 5 },
isActive: true
};
dispatch(addProduct(fallback));
newProducts.push(fallback);
}
}
if (newProducts.length > 0) {
toast.success(`Added ${newProducts.length} new products with printing costs`);
}
if (existingMatches.length > 0) {
toast.success(`Matched ${existingMatches.length} items to existing products`);
}
// Close modal immediately after successful processing
onComplete([...newProducts, ...existingMatches]);
onClose(); // Explicitly close the modal
} catch (error) {
console.error('Error processing products:', error);
toast.error('Failed to process products. Please try again.');
} finally {
setIsProcessing(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b">
<h2 className="text-xl font-semibold text-gray-900">
Missing Products - Add Printing Costs
</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6">
<p className="text-gray-600 mb-4">
The following products from your order are not in your product database.
Please add printing costs (including materials/filament) to complete the import:
</p>
<div className="space-y-4">
{missingProducts.map((product, index) => (
<div key={index} className="border rounded-lg p-4 bg-gray-50">
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Product Name
</label>
<div className="text-sm text-gray-900 font-medium">
{product.title}
</div>
<div className="text-xs text-gray-500 mt-1">
Quantity ordered: {product.quantity}{product.price ? ` × $${product.price.toFixed(2)} each` : ''}
</div>
</div>
{/* Show suggestions if available */}
{suggestions[product.title] && suggestions[product.title].length > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-blue-600" />
<span className="text-sm font-medium text-blue-900">
Possible matches found:
</span>
</div>
<div className="space-y-2">
{suggestions[product.title].map((suggestion, sugIndex) => (
<div key={sugIndex} className="flex items-center justify-between">
<div className="flex-1">
<div className="text-sm text-gray-900">
{suggestion.product.title}
</div>
<div className="text-xs text-gray-600">
{suggestion.reason} ({(suggestion.confidence * 100).toFixed(0)}% match)
</div>
</div>
<button
onClick={() => handleUseExisting(product.title, suggestion.product._id)}
className={`px-3 py-1 text-xs rounded-md border transition-colors ${
productData[product.title]?.useExisting === suggestion.product._id
? 'bg-green-100 border-green-300 text-green-700'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{productData[product.title]?.useExisting === suggestion.product._id ? (
<span className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Selected
</span>
) : (
'Use This'
)}
</button>
</div>
))}
</div>
</div>
)}
{/* Only show input fields if not using existing product */}
{!productData[product.title]?.useExisting && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Printing Cost ($)
</label>
<input
type="number"
step="0.01"
placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={productData[product.title]?.printingCost || ''}
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
/>
<p className="text-xs text-gray-500 mt-1">
Include filament, materials, and time costs
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent"
value={productData[product.title]?.category || ''}
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
>
<option value="">Select category</option>
<option value="Imported Items">Imported Items</option>
<option value="3D Printed">3D Printed</option>
<option value="Home & Living">Home & Living</option>
<option value="Art & Collectibles">Art & Collectibles</option>
<option value="Jewelry">Jewelry</option>
<option value="Other">Other</option>
</select>
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Printing Cost per Item (inc. materials)
</label>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Category
</label>
<select
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)}
>
<option value="">Select category...</option>
<option value="3D Prints">3D Prints</option>
<option value="Jewelry">Jewelry</option>
<option value="Home Decor">Home Decor</option>
<option value="Organizers">Organizers</option>
<option value="Planters">Planters</option>
<option value="Custom Items">Custom Items</option>
<option value="Other">Other</option>
</select>
</div>
</div>
</div>
))}
</div>
<div className="flex justify-end gap-3 mt-6">
<button
onClick={onClose}
className="px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={handleAddProducts}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700"
>
<Plus className="w-4 h-4" />
Add Products to Database
</button>
</div>
</div>
</div>
</div>
);
};

View file

@ -304,7 +304,11 @@ export default function DataImport() {
const handleMissingProductsSubmit = (newProducts: any[]) => {
// Products are already created by the MissingProductsModal
// We need to use the updated product list for matching
console.log('Missing products submitted:', newProducts);
// Close modal immediately and reset state
setShowMissingProductsModal(false);
setMissingProducts([]);
const slip = pdfResults.find(slip => slip.orderNumber === currentOrderForMissing);
if (slip) {
@ -312,6 +316,11 @@ export default function DataImport() {
const updatedProducts = [...products, ...newProducts];
createOrUpdateOrderFromSlip(slip, updatedProducts);
}
// Reset the current order tracking
setCurrentOrderForMissing('');
toast.success('Products processed successfully! Order has been updated.');
};
const handleClearTestData = () => {
@ -820,7 +829,11 @@ export default function DataImport() {
{showMissingProductsModal && (
<MissingProductsModal
missingProducts={missingProducts}
onClose={() => setShowMissingProductsModal(false)}
onClose={() => {
setShowMissingProductsModal(false);
setMissingProducts([]);
setCurrentOrderForMissing('');
}}
onComplete={handleMissingProductsSubmit}
/>
)}