From 851cc3c4cca7423b3d119a69ac873f2780ce0d8e Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 9 Mar 2025 13:42:33 -0400 Subject: [PATCH] Fix product search dialog for adding templates, pull out component to use independently, add to template management settings page --- .../products/ProductSearchDialog.tsx | 495 ++++++---- .../settings/TemplateManagement.tsx | 783 +-------------- .../templates/SearchProductTemplateDialog.tsx | 917 ++++++++++++++++++ .../SearchProductTemplateDialog.tsx.bak | 914 +++++++++++++++++ .../src/components/templates/TemplateForm.tsx | 706 ++++++++++++++ .../components/ValidationContainer.tsx | 3 +- 6 files changed, 2869 insertions(+), 949 deletions(-) create mode 100644 inventory/src/components/templates/SearchProductTemplateDialog.tsx create mode 100644 inventory/src/components/templates/SearchProductTemplateDialog.tsx.bak create mode 100644 inventory/src/components/templates/TemplateForm.tsx diff --git a/inventory/src/components/products/ProductSearchDialog.tsx b/inventory/src/components/products/ProductSearchDialog.tsx index 10c2ede..6664f78 100644 --- a/inventory/src/components/products/ProductSearchDialog.tsx +++ b/inventory/src/components/products/ProductSearchDialog.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import axios from 'axios'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; @@ -106,6 +106,174 @@ interface TemplateFormData { type SortDirection = 'asc' | 'desc' | null; type SortField = 'title' | 'brand' | 'line' | 'price' | 'total_sold' | 'first_received' | 'date_last_sold' | null; +// Date filter options +const DATE_FILTER_OPTIONS = [ + { label: "Any time", value: "none" }, + { label: "Last week", value: "1week" }, + { label: "Last month", value: "1month" }, + { label: "Last 2 months", value: "2months" }, + { label: "Last 3 months", value: "3months" }, + { label: "Last 6 months", value: "6months" }, + { label: "Last year", value: "1year" } +]; + +// Create a memoized search component to prevent unnecessary re-renders +const SearchInput = React.memo(({ + searchTerm, + setSearchTerm, + handleSearch, + isLoading, + onClear +}: { + searchTerm: string; + setSearchTerm: (term: string) => void; + handleSearch: () => void; + isLoading: boolean; + onClear: () => void; +}) => ( +
{ e.preventDefault(); handleSearch(); }} className="flex items-center space-x-2"> +
+ setSearchTerm(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleSearch()} + className="pr-8" + /> + {searchTerm && ( + + )} +
+ +
+)); + +// Create a memoized filter component +const FilterSelects = React.memo(({ + selectedCompany, + setSelectedCompany, + selectedDateFilter, + setSelectedDateFilter, + companies, + onFilterChange +}: { + selectedCompany: string; + setSelectedCompany: (company: string) => void; + selectedDateFilter: string; + setSelectedDateFilter: (filter: string) => void; + companies: { label: string; value: string; }[]; + onFilterChange: () => void; +}) => ( +
+
+ + +
+ +
+ + +
+
+)); + +// Create a memoized results table component +const ResultsTable = React.memo(({ + results, + selectedCompany, + onSelect +}: { + results: any[]; + selectedCompany: string; + onSelect: (product: any) => void; +}) => ( + + + + Name + {selectedCompany === 'all' && Company} + Line + Price + Total Sold + Date In + Last Sold + + + + {results.map((product) => ( + onSelect(product)} + > + {product.title} + {selectedCompany === 'all' && {product.brand || '-'}} + {product.line || '-'} + + {product.price != null ? `$${Number(product.price).toFixed(2)}` : '-'} + + {product.total_sold || 0} + + {product.first_received + ? new Date(product.first_received).toLocaleDateString() + : '-'} + + + {product.date_last_sold + ? new Date(product.date_last_sold).toLocaleDateString() + : '-'} + + + ))} + +
+)); + export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) { const [searchTerm, setSearchTerm] = useState(''); const [searchResults, setSearchResults] = useState([]); @@ -122,25 +290,84 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod const productsPerPage = 500; // Filter states - const [companyFilter, setCompanyFilter] = useState('all'); - const [dateInFilter, setDateInFilter] = useState('none'); + const [selectedCompany, setSelectedCompany] = useState('all'); + const [selectedDateFilter, setSelectedDateFilter] = useState('none'); // Sorting states const [sortField, setSortField] = useState(null); const [sortDirection, setSortDirection] = useState(null); + const [hasSearched, setHasSearched] = useState(false); + + // Add this new function to handle all search state changes + const handleSearchStateChange = async () => { + setIsLoading(true); + setHasSearched(true); + + // If no active filters or search term, reset everything + if (!searchTerm.trim() && selectedCompany === 'all' && selectedDateFilter === 'none') { + setSearchResults([]); + setHasSearched(false); + setIsLoading(false); + return; + } + + try { + const params: Record = {}; + + // Always include search term if present + if (searchTerm.trim()) { + params.q = searchTerm.trim(); + } + + // Add filters + if (selectedCompany !== 'all') { + params.company = selectedCompany; + // Only use wildcard if there's no search term + if (!searchTerm.trim()) { + params.q = '*'; + } + } + + if (selectedDateFilter !== 'none') { + params.dateRange = selectedDateFilter; + // Only use wildcard if there's no search term and no company filter + if (!searchTerm.trim() && selectedCompany === 'all') { + params.q = '*'; + } + } + + const response = await axios.get('/api/import/search-products', { params }); + setSearchResults(response.data.map((product: Product) => ({ + ...product, + pid: typeof product.pid === 'string' ? parseInt(product.pid, 10) : product.pid, + price: typeof product.price === 'string' ? parseFloat(product.price) : product.price, + // ... other parsing remains the same + }))); + } catch (error) { + console.error('Error searching products:', error); + toast.error('Failed to search products', { + description: 'Could not retrieve search results from the server' + }); + setSearchResults([]); + } finally { + setIsLoading(false); + } + }; + // Reset all search state when dialog is closed useEffect(() => { if (!isOpen) { // Reset search state setSearchTerm(''); setSearchResults([]); - setCompanyFilter('all'); - setDateInFilter('none'); + setSelectedCompany('all'); + setSelectedDateFilter('none'); setSortField(null); setSortDirection(null); setCurrentPage(1); setStep('search'); + setHasSearched(false); } }, [isOpen]); @@ -149,21 +376,18 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod if (isOpen) { fetchFieldOptions(); // Perform initial search if any filters are already applied - if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) { - performSearch(); + if (selectedCompany !== 'all' || selectedDateFilter !== 'none') { + handleSearchStateChange(); } } }, [isOpen]); - // Reset pagination when filters change and trigger search + // Update the useEffect for filter changes to use the new search function useEffect(() => { - setCurrentPage(1); - - // Trigger search when company filter changes - if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) { - performSearch(); + if (selectedCompany !== 'all' || selectedDateFilter !== 'none') { + handleSearchStateChange(); } - }, [companyFilter, dateInFilter]); + }, [selectedCompany, selectedDateFilter]); // Fetch product lines when company changes useEffect(() => { @@ -194,78 +418,20 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod } }; - // Common search function for all search scenarios - const performSearch = async () => { - setIsLoading(true); - - try { - // Prepare query parameters - const params: Record = {}; - - // Always include a search parameter - necessary for the API - // If there's a search term, use it, otherwise use a wildcard/empty search - params.q = searchTerm.trim() || "*"; - - // Add company filter if selected - if (companyFilter && companyFilter !== 'all') { - params.company = companyFilter; - } - - // Add date range filter if selected - if (dateInFilter && dateInFilter !== 'none') { - params.dateRange = dateInFilter; - } - - const response = await axios.get('/api/import/search-products', { params }); - - const parsedResults = response.data.map((product: Product) => { - const parsedProduct = { - ...product, - // Ensure pid is a number - pid: typeof product.pid === 'string' ? parseInt(product.pid, 10) : product.pid, - // Convert string numeric values to actual numbers - price: typeof product.price === 'string' ? parseFloat(product.price) : product.price, - regular_price: typeof product.regular_price === 'string' ? parseFloat(product.regular_price) : product.regular_price, - cost_price: product.cost_price !== null && typeof product.cost_price === 'string' ? parseFloat(product.cost_price) : product.cost_price, - brand_id: product.brand_id !== null ? String(product.brand_id) : '', - line_id: product.line_id !== null ? String(product.line_id) : '0', - subline_id: product.subline_id !== null ? String(product.subline_id) : '0', - artist_id: product.artist_id !== null ? String(product.artist_id) : '0', - moq: typeof product.moq === 'string' ? parseInt(product.moq, 10) : product.moq, - weight: typeof product.weight === 'string' ? parseFloat(product.weight) : product.weight, - length: typeof product.length === 'string' ? parseFloat(product.length) : product.length, - width: typeof product.width === 'string' ? parseFloat(product.width) : product.width, - height: typeof product.height === 'string' ? parseFloat(product.height) : product.height, - total_sold: typeof product.total_sold === 'string' ? parseInt(product.total_sold, 10) : product.total_sold - }; - - return parsedProduct; - }); - - setSearchResults(parsedResults); - } catch (error) { - console.error('Error searching products:', error); - toast.error('Failed to search products', { - description: 'Could not retrieve search results from the server' - }); - setSearchResults([]); - } finally { - setIsLoading(false); - } - }; - + // Update the search input handlers const handleSearch = () => { - performSearch(); + handleSearchStateChange(); }; - const handleCompanyFilterChange = (value: string) => { - setCompanyFilter(value); - // The useEffect will handle the search + const clearSearch = () => { + setSearchTerm(''); + handleSearchStateChange(); }; - - const handleDateFilterChange = (value: string) => { - setDateInFilter(value); - // The useEffect will handle the search + + const clearFilters = () => { + setSelectedCompany('all'); + setSelectedDateFilter('none'); + handleSearchStateChange(); }; const handleProductSelect = (product: Product) => { @@ -337,14 +503,12 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod try { const response = await axios.get(`/api/import/product-categories/${productId}`); - if (response.data && Array.isArray(response.data)) { // Filter out categories with type 20 (themes) and type 21 (subthemes) const filteredCategories = response.data.filter((category: any) => category.type !== 20 && category.type !== 21 ); - // Extract category IDs and update form data const categoryIds = filteredCategories.map((category: any) => category.value); setFormData(prev => ({ @@ -631,7 +795,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod return searchResults.filter(product => { // Apply company filter if set - if (companyFilter && companyFilter !== "all" && product.brand_id !== companyFilter) { + if (selectedCompany && selectedCompany !== "all" && product.brand_id !== selectedCompany) { return false; } @@ -651,19 +815,6 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered); const totalPagesFiltered = Math.ceil(sortedResults.length / productsPerPage); - const clearFilters = () => { - setCompanyFilter('all'); - setDateInFilter('none'); - - // If search term is empty, clear results completely - if (!searchTerm.trim()) { - setSearchResults([]); - } else { - // Otherwise, perform a search with just the search term - performSearch(); - } - }; - // Get sort icon const getSortIcon = (field: SortField) => { if (sortField !== field) { @@ -700,125 +851,57 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
-
-
- { - // Just update the search term without triggering search - setSearchTerm(e.target.value); - }} - onKeyDown={(e) => e.key === 'Enter' && handleSearch()} - className="pr-8" - /> - {searchTerm && ( - - )} -
- -
+ - {fieldOptions ? ( -
-
- - -
- -
- - -
-
- ) : ( -
Loading filter options...
- )} + - {fieldOptions && ((companyFilter && companyFilter !== 'all') || dateInFilter !== 'none') && ( + {fieldOptions && ((selectedCompany && selectedCompany !== 'all') || selectedDateFilter !== 'none') && (
Active filters:
- {companyFilter && companyFilter !== "all" && ( + {selectedCompany && selectedCompany !== "all" && ( - Company: {fieldOptions?.companies?.find(c => c.value === companyFilter)?.label || 'Unknown'} + Company: {fieldOptions?.companies?.find(c => c.value === selectedCompany)?.label || 'Unknown'} )} - {dateInFilter && dateInFilter !== 'none' && ( + {selectedDateFilter && selectedDateFilter !== 'none' && ( Date: {(() => { - switch(dateInFilter) { - case '1week': return 'Last week'; - case '1month': return 'Last month'; - case '2months': return 'Last 2 months'; - case '3months': return 'Last 3 months'; - case '6months': return 'Last 6 months'; - case '1year': return 'Last year'; - default: return 'Custom range'; - } + const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === selectedDateFilter); + return selectedOption ? selectedOption.label : 'Custom range'; })()} @@ -840,14 +923,17 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
{isLoading ? ( +
+ + Searching... +
+ ) : !hasSearched ? (
- Searching... + Use the search field or filters to find products
) : searchResults.length === 0 ? (
- {companyFilter !== 'all' || dateInFilter !== 'none' || searchTerm.trim() - ? 'No products found matching your criteria' - : 'Use the search field or filters to find products'} + No products found matching your criteria
) : ( <> @@ -859,7 +945,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod Name - {(!companyFilter || companyFilter === "all") && ( + {selectedCompany === 'all' && ( Company )} Line @@ -877,7 +963,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod onClick={() => handleProductSelect(product)} > {product.title} - {(!companyFilter || companyFilter === "all") && ( + {selectedCompany === 'all' && ( {product.brand || '-'} )} {product.line || '-'} @@ -1516,12 +1602,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod }; return ( - { - if (!open) { - // When dialog is closed externally (like clicking outside) - onClose(); - } - }}> + !open && onClose()}> {step === 'search' ? renderSearchStep() : renderFormStep()} diff --git a/inventory/src/components/settings/TemplateManagement.tsx b/inventory/src/components/settings/TemplateManagement.tsx index a6a6ddc..adba13f 100644 --- a/inventory/src/components/settings/TemplateManagement.tsx +++ b/inventory/src/components/settings/TemplateManagement.tsx @@ -58,6 +58,8 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; +import { SearchProductTemplateDialog } from "@/components/templates/SearchProductTemplateDialog"; +import { TemplateForm } from "@/components/templates/TemplateForm"; interface FieldOption { label: string; @@ -106,14 +108,10 @@ interface TemplateFormData extends Omit(null); const [editingTemplate, setEditingTemplate] = useState