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 React, { useState, useMemo } from 'react';
import { useDispatch } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '../store';
import { addProduct } from '../store/slices/productSlice'; 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 toast from 'react-hot-toast';
import api from '../utils/api'; import api from '../utils/api';
@ -25,66 +27,143 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
onComplete onComplete
}) => { }) => {
const dispatch = useDispatch(); const dispatch = useDispatch();
const products = useSelector((state: RootState) => state.products.products);
const [isProcessing, setIsProcessing] = useState(false);
const [productData, setProductData] = useState<{[key: string]: { const [productData, setProductData] = useState<{[key: string]: {
printingCost: number; printingCost: number;
category: string; 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) => { const handleInputChange = (productTitle: string, field: string, value: string) => {
setProductData(prev => ({ setProductData(prev => ({
...prev, ...prev,
[productTitle]: { [productTitle]: {
...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 () => { const handleAddProducts = async () => {
const newProducts: any[] = []; // Prevent multiple clicks while processing
if (isProcessing) {
for (const product of missingProducts) { console.log('Already processing, ignoring click');
const data = productData[product.title] || {}; return;
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);
}
} }
toast.success(`Added ${newProducts.length} new products with printing costs`); setIsProcessing(true);
onComplete(newProducts); 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';
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 (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)}`,
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`);
}
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 ( return (
@ -97,22 +176,27 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
<button <button
onClick={onClose} onClick={onClose}
className="text-gray-400 hover:text-gray-600" className="text-gray-400 hover:text-gray-600"
disabled={isProcessing}
> >
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="p-6"> <div className="p-6">
<p className="text-gray-600 mb-4"> <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg">
The following products from your order are not in your product database. <p className="text-blue-900 text-sm">
Please add printing costs (including materials/filament) to complete the import: <strong>Found unmatched product:</strong> "{missingProducts[0]?.title}"
</p> </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"> <div className="space-y-4">
{missingProducts.map((product, index) => ( {missingProducts.map((product, index) => (
<div key={index} className="border rounded-lg p-4 bg-gray-50"> <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="grid grid-cols-1 gap-4">
<div className="md:col-span-2"> <div>
<label className="block text-sm font-medium text-gray-700 mb-1"> <label className="block text-sm font-medium text-gray-700 mb-1">
Product Name Product Name
</label> </label>
@ -123,57 +207,125 @@ export const MissingProductsModal: React.FC<MissingProductsModalProps> = ({
Quantity ordered: {product.quantity}{product.price ? ` × $${product.price.toFixed(2)} each` : ''} Quantity ordered: {product.quantity}{product.price ? ` × $${product.price.toFixed(2)} each` : ''}
</div> </div>
</div> </div>
<div> {/* Show suggestions if available */}
<label className="block text-sm font-medium text-gray-700 mb-1"> {suggestions[product.title] && suggestions[product.title].length > 0 && (
Printing Cost per Item (inc. materials) <div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
</label> <div className="flex items-center gap-2 mb-2">
<input <AlertCircle className="w-4 h-4 text-blue-600" />
type="number" <span className="text-sm font-medium text-blue-900">
step="0.01" Possible matches found:
min="0" </span>
placeholder="0.00" </div>
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" <div className="space-y-2">
onChange={(e) => handleInputChange(product.title, 'printingCost', e.target.value)} {suggestions[product.title].map((suggestion, sugIndex) => (
/> <div key={sugIndex} className="flex items-center justify-between">
</div> <div className="flex-1">
<div className="text-sm text-gray-900">
<div> {suggestion.product.title}
<label className="block text-sm font-medium text-gray-700 mb-1"> </div>
Category <div className="text-xs text-gray-600">
</label> {suggestion.reason} ({(suggestion.confidence * 100).toFixed(0)}% match)
<select </div>
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" </div>
onChange={(e) => handleInputChange(product.title, 'category', e.target.value)} <button
> onClick={() => handleUseExisting(product.title, suggestion.product._id)}
<option value="">Select category...</option> className={`px-3 py-1 text-xs rounded-md border transition-colors ${
<option value="3D Prints">3D Prints</option> productData[product.title]?.useExisting === suggestion.product._id
<option value="Jewelry">Jewelry</option> ? 'bg-green-100 border-green-300 text-green-700'
<option value="Home Decor">Home Decor</option> : 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
<option value="Organizers">Organizers</option> }`}
<option value="Planters">Planters</option> disabled={isProcessing}
<option value="Custom Items">Custom Items</option> >
<option value="Other">Other</option> {productData[product.title]?.useExisting === suggestion.product._id ? (
</select> <span className="flex items-center gap-1">
</div> <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)}
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: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="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>
<div className="flex justify-end gap-3 mt-6"> <div className="flex gap-3 mt-6 justify-end">
<button <button
onClick={onClose} 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 Cancel
</button> </button>
<button <button
onClick={handleAddProducts} 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}
> >
<Plus className="w-4 h-4" /> {isProcessing ? (
Add Products to Database <>
<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> </button>
</div> </div>
</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[]) => { const handleMissingProductsSubmit = (newProducts: any[]) => {
// Products are already created by the MissingProductsModal // Products are already created by the MissingProductsModal
// We need to use the updated product list for matching // We need to use the updated product list for matching
console.log('Missing products submitted:', newProducts);
// Close modal immediately and reset state
setShowMissingProductsModal(false); setShowMissingProductsModal(false);
setMissingProducts([]);
const slip = pdfResults.find(slip => slip.orderNumber === currentOrderForMissing); const slip = pdfResults.find(slip => slip.orderNumber === currentOrderForMissing);
if (slip) { if (slip) {
@ -312,6 +316,11 @@ export default function DataImport() {
const updatedProducts = [...products, ...newProducts]; const updatedProducts = [...products, ...newProducts];
createOrUpdateOrderFromSlip(slip, updatedProducts); createOrUpdateOrderFromSlip(slip, updatedProducts);
} }
// Reset the current order tracking
setCurrentOrderForMissing('');
toast.success('Products processed successfully! Order has been updated.');
}; };
const handleClearTestData = () => { const handleClearTestData = () => {
@ -820,7 +829,11 @@ export default function DataImport() {
{showMissingProductsModal && ( {showMissingProductsModal && (
<MissingProductsModal <MissingProductsModal
missingProducts={missingProducts} missingProducts={missingProducts}
onClose={() => setShowMissingProductsModal(false)} onClose={() => {
setShowMissingProductsModal(false);
setMissingProducts([]);
setCurrentOrderForMissing('');
}}
onComplete={handleMissingProductsSubmit} onComplete={handleMissingProductsSubmit}
/> />
)} )}