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:
parent
461e424e5e
commit
f39d4ca266
3 changed files with 600 additions and 91 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
344
client/src/components/MissingProductsModal.tsx.backup
Normal file
344
client/src/components/MissingProductsModal.tsx.backup
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue