From 8271c9f95a350ccacbef55a1304dd6c4e80ada70 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 1 Mar 2025 14:48:10 -0500 Subject: [PATCH] Improve template search in validate step --- .../products/ProductSearchDialog.tsx | 9 +- .../steps/ValidationStep/ValidationStep.tsx | 779 ++++++++++++++---- 2 files changed, 643 insertions(+), 145 deletions(-) diff --git a/inventory/src/components/products/ProductSearchDialog.tsx b/inventory/src/components/products/ProductSearchDialog.tsx index 6ae7f68..f49f8c8 100644 --- a/inventory/src/components/products/ProductSearchDialog.tsx +++ b/inventory/src/components/products/ProductSearchDialog.tsx @@ -6,15 +6,13 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Loader2, Search, ChevronsUpDown, Check, ChevronLeft, ChevronRight, MoreHorizontal, X, ChevronUp, ChevronDown } from 'lucide-react'; +import { Loader2, Search, ChevronsUpDown, Check, X, ChevronUp, ChevronDown } from 'lucide-react'; import { toast } from 'sonner'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'; import { cn } from '@/lib/utils'; import { ScrollArea } from '@/components/ui/scroll-area'; -import { format } from 'date-fns'; -import { Calendar } from '@/components/ui/calendar'; import { Pagination, PaginationContent, @@ -118,7 +116,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod product_type: '', }); const [fieldOptions, setFieldOptions] = useState(null); - const [productLines, setProductLines] = useState([]); + const [, setProductLines] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); const [currentPage, setCurrentPage] = useState(1); const productsPerPage = 500; @@ -528,9 +526,6 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod }; // Get current products for pagination - const indexOfLastProduct = currentPage * productsPerPage; - const indexOfFirstProduct = indexOfLastProduct - productsPerPage; - const currentProducts = searchResults.slice(indexOfFirstProduct, indexOfLastProduct); const totalPages = Math.ceil(searchResults.length / productsPerPage); // Change page diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx index ee39de6..06f898b 100644 --- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx +++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStep/ValidationStep.tsx @@ -846,13 +846,45 @@ function useTemplates( newTemplateType: "", }) + // Fetch field options to get company names + const { data: fieldOptions } = useQuery({ + queryKey: ["field-options"], + queryFn: async () => { + const response = await fetch(`${config.apiUrl}/import/field-options`); + if (!response.ok) { + throw new Error("Failed to fetch field options"); + } + return response.json(); + }, + staleTime: 600000, // 10 minutes + }); + // Function to fetch templates const fetchTemplates = useCallback(async () => { try { + console.log('Fetching templates...'); const response = await fetch(`${config.apiUrl}/templates`) + console.log('Templates response status:', response.status); + if (!response.ok) throw new Error('Failed to fetch templates') + const templateData = await response.json() - setTemplates(templateData) + console.log('Templates fetched successfully:', templateData); + console.log('First template:', templateData[0]); + + // Validate template data + const validTemplates = templateData.filter((t: any) => + t && typeof t === 'object' && t.id && t.company && t.product_type + ); + + if (validTemplates.length !== templateData.length) { + console.warn('Some templates were filtered out due to invalid data', { + original: templateData.length, + valid: validTemplates.length + }); + } + + setTemplates(validTemplates) } catch (error) { console.error('Error fetching templates:', error) toast({ @@ -883,108 +915,155 @@ function useTemplates( const applyTemplate = useCallback(async (templateId: string, rowIndices?: number[]) => { if (!templateId) return - const template = templates?.find(t => t.id.toString() === templateId) - if (!template) return + try { + const template = templates?.find(t => t && t.id && t.id.toString() === templateId) + if (!template) { + console.error(`Template with ID ${templateId} not found`); + toast({ + title: "Error", + description: `Template not found`, + variant: "destructive", + }); + return; + } - setData((prevData: RowData[]) => { - const newData = [...prevData] - const indicesToUpdate = rowIndices || newData.map((_, i) => i) - - indicesToUpdate.forEach(index => { - const row = newData[index] - if (!row) return + setData((prevData: RowData[]) => { + try { + const newData = [...prevData] + const indicesToUpdate = rowIndices || newData.map((_, i) => i) + + indicesToUpdate.forEach(index => { + const row = newData[index] + if (!row) return - // Apply all template fields except id, company, product_type, created_at, and updated_at - Object.entries(template).forEach(([key, value]) => { - if (!['id', 'company', 'product_type', 'created_at', 'updated_at'].includes(key)) { - // Handle numeric values that might be stored as strings - if (typeof value === 'string' && /^\d+(\.\d+)?$/.test(value)) { - // If it's a price field, add the dollar sign - if (['msrp', 'cost_each'].includes(key)) { - row[key as keyof typeof row] = `$${value}` as any; - } else { - row[key as keyof typeof row] = value as any; - } - } - // Special handling for categories field - else if (key === 'categories') { - console.log('Applying categories from template:', { - key, - value, - type: typeof value, - isArray: Array.isArray(value) - }); - - // If categories is an array, use it directly - if (Array.isArray(value)) { - row[key as keyof typeof row] = value as any; - console.log('Categories is array, using directly:', value); - } - // If categories is a string (possibly a PostgreSQL array representation), parse it - else if (typeof value === 'string') { - try { - // Try to parse as JSON if it's a JSON string - if (value.startsWith('[') && value.endsWith(']')) { - const parsed = JSON.parse(value); - row[key as keyof typeof row] = parsed as any; - console.log('Categories parsed from JSON string:', parsed); + // Apply all template fields except id, company, product_type, created_at, and updated_at + Object.entries(template).forEach(([key, value]) => { + if (!['id', 'company', 'product_type', 'created_at', 'updated_at'].includes(key)) { + // Handle numeric values that might be stored as strings + if (typeof value === 'string' && /^\d+(\.\d+)?$/.test(value)) { + // If it's a price field, add the dollar sign + if (['msrp', 'cost_each'].includes(key)) { + row[key as keyof typeof row] = `$${value}` as any; + } else { + row[key as keyof typeof row] = value as any; + } + } + // Special handling for categories field + else if (key === 'categories') { + console.log('Applying categories from template:', { + key, + value, + type: typeof value, + isArray: Array.isArray(value) + }); + + // If categories is an array, use it directly + if (Array.isArray(value)) { + row[key as keyof typeof row] = value as any; + console.log('Categories is array, using directly:', value); } - // Handle PostgreSQL array format like {value1,value2} - else if (value.startsWith('{') && value.endsWith('}')) { - const parsed = value.substring(1, value.length - 1).split(','); - row[key as keyof typeof row] = parsed as any; - console.log('Categories parsed from PostgreSQL array:', parsed); + // If categories is a string (possibly a PostgreSQL array representation), parse it + else if (typeof value === 'string') { + try { + // Try to parse as JSON if it's a JSON string + if (value.startsWith('[') && value.endsWith(']')) { + const parsed = JSON.parse(value); + row[key as keyof typeof row] = parsed as any; + console.log('Categories parsed from JSON string:', parsed); + } + // Otherwise, it might be a PostgreSQL array format like {val1,val2} + else if (value.startsWith('{') && value.endsWith('}')) { + const parsed = value.slice(1, -1).split(','); + row[key as keyof typeof row] = parsed as any; + console.log('Categories parsed from PostgreSQL array:', parsed); + } + // If it's a single value, wrap it in an array + else { + row[key as keyof typeof row] = [value] as any; + console.log('Categories is single value, wrapping in array:', [value]); + } + } catch (error) { + console.error('Error parsing categories:', error); + // If parsing fails, use as-is + row[key as keyof typeof row] = value as any; + } } - // If it's a comma-separated string - else if (value.includes(',')) { - const parsed = value.split(','); - row[key as keyof typeof row] = parsed as any; - console.log('Categories parsed from comma-separated string:', parsed); - } - // If it's a single value - else if (value.trim()) { - const parsed = [value.trim()]; - row[key as keyof typeof row] = parsed as any; - console.log('Categories parsed from single value:', parsed); - } - // Empty value + // For any other type, use as-is else { - row[key as keyof typeof row] = [] as any; - console.log('Categories is empty string, using empty array'); + row[key as keyof typeof row] = value as any; } - } catch (e) { - console.error('Error parsing categories:', e); - row[key as keyof typeof row] = [] as any; + } + // Handle ship_restrictions field similarly to categories + else if (key === 'ship_restrictions') { + console.log('Applying ship_restrictions from template:', { + key, + value, + type: typeof value, + isArray: Array.isArray(value) + }); + + // If ship_restrictions is an array, use it directly + if (Array.isArray(value)) { + row[key as keyof typeof row] = value as any; + } + // If ship_restrictions is a string, try to parse it + else if (typeof value === 'string') { + try { + // Try to parse as JSON if it's a JSON string + if (value.startsWith('[') && value.endsWith(']')) { + const parsed = JSON.parse(value); + row[key as keyof typeof row] = parsed as any; + } + // Otherwise, it might be a PostgreSQL array format like {val1,val2} + else if (value.startsWith('{') && value.endsWith('}')) { + const parsed = value.slice(1, -1).split(','); + row[key as keyof typeof row] = parsed as any; + } + // If it's a single value, wrap it in an array + else { + row[key as keyof typeof row] = [value] as any; + } + } catch (error) { + console.error('Error parsing ship_restrictions:', error); + // If parsing fails, use as-is + row[key as keyof typeof row] = value as any; + } + } + // For any other type, use as-is + else { + row[key as keyof typeof row] = value as any; + } + } + // For all other fields, apply directly + else { + row[key as keyof typeof row] = value as any; } } - // Default to empty array for any other case - else { - row[key as keyof typeof row] = [] as any; - console.log('Categories is not array or string, using empty array'); - } - } - // Handle other array values - else if (Array.isArray(value)) { - row[key as keyof typeof row] = [...value] as any; - } - // Handle other values - else { - row[key as keyof typeof row] = value as any; - } - } - }) + }); - // Update the template reference - row.__template = templateId; + // Update the template reference + row.__template = templateId; + }) + + return newData + } catch (error) { + console.error('Error applying template:', error); + return prevData; + } }) - return newData - }) - - toast({ - title: "Template Applied", - description: `Applied template to ${rowIndices?.length || data.length} row(s)`, - }) + toast({ + title: "Template Applied", + description: `Applied template to ${rowIndices?.length || data.length} row(s)`, + }) + } catch (error) { + console.error('Error in applyTemplate:', error); + toast({ + title: "Error", + description: "Failed to apply template", + variant: "destructive", + }); + } }, [templates, setData, data.length, toast]) const saveAsTemplate = useCallback(async () => { @@ -1089,6 +1168,36 @@ function useTemplates( } }, [state, data, rowSelection, toast, setTemplates, setState, setData]); + // Helper function to get company name from ID + const getCompanyName = useCallback((companyId: string) => { + if (!fieldOptions || !fieldOptions.companies) return companyId; + try { + const company = fieldOptions.companies.find((c: { value: string; label: string }) => c.value === companyId); + return company ? company.label : companyId; + } catch (error) { + console.error("Error getting company name:", error); + return companyId; + } + }, [fieldOptions]); + + // Format template display text + const getTemplateDisplayText = useCallback((template: Template) => { + if (!template) { + console.error("Template is null or undefined in getTemplateDisplayText"); + return "Unknown Template"; + } + + try { + const companyId = template.company || ""; + const productType = template.product_type || "Unknown Type"; + const companyName = getCompanyName(companyId); + return `${companyName} - ${productType}`; + } catch (error) { + console.error("Error formatting template display text:", error, template); + return "Error displaying template"; + } + }, [getCompanyName]); + return { templates, selectedTemplateId: state.selectedTemplateId, @@ -1101,10 +1210,370 @@ function useTemplates( newTemplateType: state.newTemplateType, applyTemplate, saveAsTemplate, + getTemplateDisplayText, } } -// Add this component before the ValidationStep component +// Add this component before the SaveTemplateDialog component +const SearchableTemplateSelect = memo(({ + templates, + value, + onValueChange, + getTemplateDisplayText, + placeholder = "Select template", + className, + triggerClassName, + defaultBrand, +}: { + templates: Template[]; + value: string; + onValueChange: (value: string) => void; + getTemplateDisplayText: (template: Template) => string; + placeholder?: string; + className?: string; + triggerClassName?: string; + defaultBrand?: string; +}) => { + const [searchTerm, setSearchTerm] = useState(""); + const [selectedBrand, setSelectedBrand] = useState(null); + const [open, setOpen] = useState(false); + const [error, setError] = useState(null); + const [isTemplatesReady, setIsTemplatesReady] = useState(false); + + // Set default brand when component mounts or defaultBrand changes + useEffect(() => { + if (defaultBrand) { + setSelectedBrand(defaultBrand); + } + }, [defaultBrand]); + + // Handle wheel events for scrolling + const handleWheel = (e: React.WheelEvent) => { + const scrollArea = e.currentTarget; + scrollArea.scrollTop += e.deltaY; + }; + + // Log props for debugging + useEffect(() => { + console.log('SearchableTemplateSelect props:', { + templatesCount: templates?.length || 0, + value, + hasGetTemplateDisplayText: !!getTemplateDisplayText, + placeholder, + className, + triggerClassName + }); + + // Check if templates are valid + if (templates && templates.length > 0) { + const firstTemplate = templates[0]; + console.log('First template:', firstTemplate); + setIsTemplatesReady(true); + } else { + setIsTemplatesReady(false); + } + }, [templates, value, getTemplateDisplayText, placeholder, className, triggerClassName]); + + // Extract unique brands from templates + const brands = useMemo(() => { + try { + if (!templates || templates.length === 0) return []; + + const brandSet = new Set(); + const brandNames: {id: string, name: string}[] = []; + + templates.forEach(template => { + if (!template || !template.company) return; + + const companyId = template.company; + if (!brandSet.has(companyId)) { + brandSet.add(companyId); + + // Try to get the company name from the template display text + try { + const displayText = getTemplateDisplayText(template); + const companyName = displayText.split(' - ')[0]; + brandNames.push({ id: companyId, name: companyName || `Company ${companyId}` }); + } catch (err) { + brandNames.push({ id: companyId, name: `Company ${companyId}` }); + } + } + }); + + return brandNames.sort((a, b) => a.name.localeCompare(b.name)); + } catch (err) { + console.error("Error extracting brands:", err); + return []; + } + }, [templates, getTemplateDisplayText]); + + // Group templates by company for better organization + const groupedTemplates = useMemo(() => { + try { + if (!templates || templates.length === 0) return {}; + + const groups: Record = {}; + + templates.forEach(template => { + if (!template) return; + + const companyId = template.company; + if (!groups[companyId]) { + groups[companyId] = []; + } + groups[companyId].push(template); + }); + + return groups; + } catch (err) { + console.error("Error grouping templates:", err); + return {}; + } + }, [templates]); + + // Filter templates based on selected brand and search term + const filteredTemplates = useMemo(() => { + try { + if (!templates || templates.length === 0) return []; + + // First filter by brand if selected + let brandFiltered = templates; + if (selectedBrand) { + brandFiltered = templates.filter(t => t && t.company === selectedBrand); + } + + // Then filter by search term if provided + if (!searchTerm.trim()) return brandFiltered; + + const lowerSearchTerm = searchTerm.toLowerCase(); + return brandFiltered.filter(template => { + if (!template) return false; + try { + const displayText = getTemplateDisplayText(template); + const productType = template.product_type?.toLowerCase() || ''; + + // Search in both the display text and product type + return displayText.toLowerCase().includes(lowerSearchTerm) || + productType.includes(lowerSearchTerm); + } catch (error) { + console.error("Error filtering template:", error, template); + return false; + } + }); + } catch (err) { + console.error("Error in filteredTemplates:", err); + setError("Error filtering templates"); + return []; + } + }, [templates, selectedBrand, searchTerm, getTemplateDisplayText]); + + // Get the display text for the selected template + const selectedTemplate = useMemo(() => { + try { + if (!templates || templates.length === 0 || !value) return null; + return templates.find(t => t && t.id && t.id.toString() === value); + } catch (err) { + console.error("Error finding selected template:", err); + setError("Error finding selected template"); + return null; + } + }, [templates, value]); + + // Handle errors gracefully + const getDisplayText = useCallback((template: Template | null) => { + try { + if (!template) return placeholder; + return getTemplateDisplayText(template); + } catch (err) { + console.error("Error getting template display text:", err); + setError("Error displaying template"); + return placeholder; + } + }, [getTemplateDisplayText, placeholder]); + + // Reset filters + const resetFilters = useCallback(() => { + setSearchTerm(""); + setSelectedBrand(null); + }, []); + + // Handle errors in the component + if (error) { + return ( + + ); + } + + // Safe render function for CommandItem + const renderCommandItem = useCallback((template: Template) => { + try { + return ( + { + try { + onValueChange(currentValue); + setOpen(false); + setSearchTerm(""); + setSelectedBrand(null); + } catch (err) { + console.error("Error in onSelect:", err); + setError("Error selecting template"); + } + }} + className="flex items-start gap-2 py-2" + > + +
+ {getDisplayText(template)} +
+
+ ); + } catch (err) { + console.error("Error rendering CommandItem:", err, template); + return null; + } + }, [getDisplayText, onValueChange, value]); + + // Ensure we have templates before rendering the dropdown + if (!templates || templates.length === 0) { + return ( + + ); + } + + return ( + { + try { + // Only allow opening if templates are ready + if (newOpen && !isTemplatesReady) { + console.warn("Prevented opening popover because templates are not ready"); + return; + } + setOpen(newOpen); + if (!newOpen) { + // Reset filters when closing + resetFilters(); + } + } catch (err) { + console.error("Error in onOpenChange:", err); + setError("Error opening dropdown"); + } + }}> + + + + + + {/* Filter controls */} +
+ {/* Brand filter */} +
+
+ +
+
+ + {/* Search input */} +
+ { + try { + setSearchTerm(value); + } catch (err) { + console.error("Error in onValueChange:", err); + setError("Error searching templates"); + } + }} + className="h-8 flex-1" + /> + +
+
+ + {/* Results */} + +
+

No templates found.

+
+
+ + + + {!selectedBrand && !searchTerm ? ( + // When no filters are applied, show templates grouped by company + Object.entries(groupedTemplates).map(([companyId, companyTemplates]) => { + // Get company name from the first template + const brand = brands.find(b => b.id === companyId); + const companyName = brand ? brand.name : `Company ${companyId}`; + + return ( + + {companyTemplates.map(template => renderCommandItem(template))} + + ); + }) + ) : ( + // When filters are applied, show filtered results + + {filteredTemplates.map(template => template ? renderCommandItem(template) : null)} + + )} + + +
+
+
+ ); +}); + const SaveTemplateDialog = memo(({ isOpen, onClose, @@ -1426,6 +1895,7 @@ export const ValidationStep = ({ saveAsTemplate, setNewTemplateName, setNewTemplateType, + getTemplateDisplayText, } = useTemplates(data, setData, useToast, rowSelection) // Memoize filtered data to prevent recalculation on every render @@ -1527,31 +1997,52 @@ export const ValidationStep = ({ { id: "template", header: "Template", - cell: ({ row }) => ( - - ), + cell: ({ row }) => { + try { + // Only render the component if templates are available + if (!templates || templates.length === 0) { + return ( + + ); + } + + return ( + { + try { + const newData = [...data]; + const index = newData.findIndex(r => r.__index === row.original.__index); + if (index !== -1) { + newData[index] = { ...newData[index], __template: value }; + setData(newData); + applyTemplate(value, [index]); + } + } catch (error) { + console.error("Error applying template in cell:", error); + toast({ + title: "Error", + description: "Failed to apply template to row", + variant: "destructive", + }); + } + }} + getTemplateDisplayText={getTemplateDisplayText} + defaultBrand={globalSelections?.company} + /> + ); + } catch (error) { + console.error("Error rendering template cell:", error); + return ( + + ); + } + }, size: 200, }, ...(Array.from(fields as ReadonlyFields).map((field): ColumnDef & ExtendedMeta> => ({ @@ -1589,7 +2080,7 @@ export const ValidationStep = ({ }))) ] return baseColumns - }, [fields, updateRows, data, copyValueDown, templates, applyTemplate, productLines, sublines]) + }, [fields, updateRows, data, copyValueDown, templates, applyTemplate, productLines, sublines, getTemplateDisplayText]) const table = useReactTable({ data: filteredData, @@ -2839,25 +3330,37 @@ export const ValidationStep = ({ - +
+ {/* Wrap in a fragment instead of an IIFE */} + {templates && templates.length > 0 ? ( + { + try { + setSelectedTemplateId(value); + const selectedRows = Object.keys(rowSelection).map(Number); + applyTemplate(value, selectedRows); + } catch (error) { + console.error("Error applying template to selection:", error); + toast({ + title: "Error", + description: "Failed to apply template to selected rows", + variant: "destructive", + }); + } + }} + getTemplateDisplayText={getTemplateDisplayText} + placeholder="Select template" + triggerClassName="w-[200px]" + defaultBrand={globalSelections?.company} + /> + ) : ( + + )} +
{Object.keys(rowSelection).length === 1 && (