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;
+}) => (
+
+));
+
+// 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;
+}) => (
+
+
+ Filter by Company
+ {
+ setSelectedCompany(value);
+ onFilterChange();
+ }}
+ >
+
+
+
+
+ Any Company
+ {companies.map((company) => (
+
+ {company.label}
+
+ ))}
+
+
+
+
+
+ Filter by Date Received
+ {
+ setSelectedDateFilter(value);
+ onFilterChange();
+ }}
+ >
+
+
+
+
+ {DATE_FILTER_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+));
+
+// 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 && (
- {
- setSearchTerm('');
- // If there are active filters, perform a search with just the filters
- if (companyFilter !== 'all' || dateInFilter !== 'none') {
- setTimeout(() => performSearch(), 0);
- } else {
- // Otherwise, clear the results completely
- setSearchResults([]);
- }
- }}
- >
-
-
- )}
-
-
- {isLoading ? : }
-
-
+
- {fieldOptions ? (
-
-
- Filter by Company
-
-
-
-
-
- All Companies
- {fieldOptions.companies?.map((company) => (
-
- {company.label}
-
- ))}
-
-
-
-
-
- Filter by Date Received
-
-
-
-
-
- Any time
- Last week
- Last month
- Last 2 months
- Last 3 months
- Last 6 months
- Last year
-
-
-
-
- ) : (
-
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'}
handleCompanyFilterChange('all')}
+ onClick={() => {
+ setSelectedCompany('all');
+ handleSearchStateChange();
+ }}
>
)}
- {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';
})()}
handleDateFilterChange("none")}
+ onClick={() => {
+ setSelectedDateFilter('none');
+ handleSearchStateChange();
+ }}
>
@@ -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(null);
- const [formData, setFormData] = useState({
- company: "",
- product_type: "",
- });
const [sorting, setSorting] = useState([]);
const [searchQuery, setSearchQuery] = useState("");
@@ -141,56 +139,6 @@ export function TemplateManagement() {
},
});
- const createMutation = useMutation({
- mutationFn: async (data: TemplateFormData) => {
- const response = await fetch(`${config.apiUrl}/templates`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(data),
- });
- if (!response.ok) {
- throw new Error("Failed to create template");
- }
- return response.json();
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["templates"] });
- setIsCreateOpen(false);
- setFormData({ company: "", product_type: "" });
- toast.success("Template created successfully");
- },
- onError: (error) => {
- toast.error(error instanceof Error ? error.message : "Failed to create template");
- },
- });
-
- const updateMutation = useMutation({
- mutationFn: async ({ id, data }: { id: number; data: TemplateFormData }) => {
- const response = await fetch(`${config.apiUrl}/templates/${id}`, {
- method: "PUT",
- headers: {
- "Content-Type": "application/json",
- },
- body: JSON.stringify(data),
- });
- if (!response.ok) {
- throw new Error("Failed to update template");
- }
- return response.json();
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["templates"] });
- setIsEditOpen(false);
- setEditingTemplate(null);
- toast.success("Template updated successfully");
- },
- onError: (error) => {
- toast.error(error instanceof Error ? error.message : "Failed to update template");
- },
- });
-
const deleteMutation = useMutation({
mutationFn: async (id: number) => {
const response = await fetch(`${config.apiUrl}/templates/${id}`, {
@@ -209,100 +157,19 @@ export function TemplateManagement() {
},
});
- const handleSubmit = (e: React.FormEvent) => {
- e.preventDefault();
- if (editingTemplate) {
- updateMutation.mutate({ id: editingTemplate.id, data: formData });
- } else {
- createMutation.mutate(formData);
- }
- };
-
const handleEdit = (template: Template) => {
setEditingTemplate(template);
- setFormData({
- company: template.company,
- product_type: template.product_type,
- supplier: template.supplier,
- msrp: template.msrp,
- cost_each: template.cost_each,
- qty_per_unit: template.qty_per_unit,
- case_qty: template.case_qty,
- hts_code: template.hts_code,
- description: template.description,
- weight: template.weight,
- length: template.length,
- width: template.width,
- height: template.height,
- tax_cat: template.tax_cat,
- size_cat: template.size_cat,
- categories: template.categories,
- ship_restrictions: template.ship_restrictions,
- });
- setIsEditOpen(true);
};
- const handleInputChange = (e: React.ChangeEvent) => {
- const { name, value } = e.target;
- setFormData((prev) => ({
- ...prev,
- [name]: value,
- }));
- };
-
- const handleSelectChange = (name: string, value: string) => {
- setFormData((prev) => ({
- ...prev,
- [name]: value,
- }));
- };
-
- const handleMultiSelectChange = (name: string, value: string) => {
- setFormData((prev) => {
- const currentValues = prev[name as keyof typeof prev] as string[] || [];
- const valueSet = new Set(currentValues);
-
- if (valueSet.has(value)) {
- valueSet.delete(value);
- } else {
- valueSet.add(value);
- }
-
- return {
- ...prev,
- [name]: Array.from(valueSet),
- };
- });
- };
-
- const handleNumberInputChange = (e: React.ChangeEvent) => {
- const { name, value } = e.target;
- setFormData((prev) => ({
- ...prev,
- [name]: value === "" ? undefined : Number(value),
- }));
- };
-
- const handleWheel = (e: React.WheelEvent) => {
- const commandList = e.currentTarget;
- commandList.scrollTop += e.deltaY;
- e.stopPropagation();
- };
-
- const handleSelect = (name: string, value: string, closeOnSelect = true) => {
- handleSelectChange(name, value);
- if (closeOnSelect) {
- const button = document.activeElement as HTMLElement;
- button?.blur();
- }
- };
-
- const handleTextAreaChange = (e: React.ChangeEvent) => {
- const { name, value } = e.target;
- setFormData((prev) => ({
- ...prev,
- [name]: value,
- }));
+ const handleCopy = (template: Template) => {
+ // Remove id and timestamps for copy operation
+ const { id, created_at, updated_at, ...templateData } = template;
+ // Set as new template with empty product type
+ setEditingTemplate({
+ ...templateData,
+ product_type: '',
+ id: 0 // Add a temporary ID to indicate it's a copy
+ } as Template);
};
const handleDeleteClick = (template: Template) => {
@@ -318,27 +185,8 @@ export function TemplateManagement() {
}
};
- const handleCopy = (template: Template) => {
- setFormData({
- company: template.company,
- product_type: template.product_type,
- supplier: template.supplier,
- msrp: template.msrp,
- cost_each: template.cost_each,
- qty_per_unit: template.qty_per_unit,
- case_qty: template.case_qty,
- hts_code: template.hts_code,
- description: template.description,
- weight: template.weight,
- length: template.length,
- width: template.width,
- height: template.height,
- tax_cat: template.tax_cat,
- size_cat: template.size_cat,
- categories: template.categories,
- ship_restrictions: template.ship_restrictions,
- });
- setIsCreateOpen(true);
+ const handleTemplateSuccess = () => {
+ queryClient.invalidateQueries({ queryKey: ["templates"] });
};
const columns = useMemo[]>(() => [
@@ -437,558 +285,18 @@ export function TemplateManagement() {
getCoreRowModel: getCoreRowModel(),
});
- const renderFormFields = () => {
- if (!fieldOptions) {
- return (
-
-
-
- );
- }
-
- // Helper function to sort options with selected value at top
- const getSortedOptions = (options: FieldOption[], selectedValue?: string | string[]) => {
- return [...options].sort((a, b) => {
- if (Array.isArray(selectedValue)) {
- const aSelected = selectedValue.includes(a.value);
- const bSelected = selectedValue.includes(b.value);
- if (aSelected && !bSelected) return -1;
- if (!aSelected && bSelected) return 1;
- return 0;
- }
- if (a.value === selectedValue) return -1;
- if (b.value === selectedValue) return 1;
- return 0;
- });
- };
-
- // Helper function to get selected category labels
- const getSelectedCategoryLabels = () => {
- if (!formData.categories?.length) return "";
- const labels = formData.categories
- .map(value => fieldOptions.categories.find(cat => cat.value === value)?.label)
- .filter(Boolean);
- return labels.join(", ");
- };
-
- // Sort categories with selected ones first and respect levels
- const getSortedCategories = () => {
- const selected = new Set(formData.categories || []);
- return [...fieldOptions.categories].sort((a, b) => {
- const aSelected = selected.has(a.value);
- const bSelected = selected.has(b.value);
- if (aSelected && !bSelected) return -1;
- if (!aSelected && bSelected) return 1;
- return 0;
- });
- };
-
- return (
-
-
-
-
Company*
-
-
-
- {formData.company
- ? fieldOptions.companies.find(
- (company) => company.value === formData.company
- )?.label
- : "Select company..."}
-
-
-
-
-
-
-
- No companies found.
-
- {getSortedOptions(fieldOptions.companies, formData.company).map((company) => (
- handleSelect("company", company.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- handleSelect("company", company.value);
- }
- }}
- >
-
- {company.label}
-
- ))}
-
-
-
-
-
-
-
- Product Type*
-
-
-
-
-
-
Supplier
-
-
-
- {formData.supplier
- ? fieldOptions.suppliers.find(
- (supplier) => supplier.value === formData.supplier
- )?.label
- : "Select supplier..."}
-
-
-
-
-
-
-
- No suppliers found.
-
- {getSortedOptions(fieldOptions.suppliers, formData.supplier).map((supplier) => (
- handleSelect("supplier", supplier.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- handleSelect("supplier", supplier.value);
- }
- }}
- >
-
- {supplier.label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
-
Shipping Restrictions
-
-
-
- {formData.ship_restrictions?.[0]
- ? fieldOptions.shippingRestrictions.find(
- (r) => r.value === formData.ship_restrictions?.[0]
- )?.label
- : "Select shipping restriction..."}
-
-
-
-
-
-
-
- No restrictions found.
-
- {getSortedOptions(fieldOptions.shippingRestrictions, formData.ship_restrictions?.[0]).map((restriction) => (
- {
- const foundRestriction = fieldOptions.shippingRestrictions.find(
- r => r.label === value
- );
- if (foundRestriction) {
- handleSelect("ship_restrictions", foundRestriction.value);
- }
- }}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- const foundRestriction = fieldOptions.shippingRestrictions.find(
- r => r.label === restriction.label
- );
- if (foundRestriction) {
- handleSelect("ship_restrictions", foundRestriction.value);
- }
- }
- }}
- >
-
- {restriction.label}
-
- ))}
-
-
-
-
-
-
-
- HTS Code
-
-
-
-
-
-
-
-
-
-
Tax Category
-
-
-
- {formData.tax_cat !== undefined
- ? fieldOptions.taxCategories.find(
- (cat) => cat.value === formData.tax_cat
- )?.label
- : "Select tax category..."}
-
-
-
-
-
-
-
- No tax categories found.
-
- {getSortedOptions(fieldOptions.taxCategories, formData.tax_cat).map((category) => (
- handleSelect("tax_cat", category.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- handleSelect("tax_cat", category.value);
- }
- }}
- >
-
- {category.label}
-
- ))}
-
-
-
-
-
-
-
-
Size Category
-
-
-
- {formData.size_cat
- ? fieldOptions.sizes.find(
- (size) => size.value === formData.size_cat
- )?.label
- : "Select size category..."}
-
-
-
-
-
-
-
- No sizes found.
-
- {getSortedOptions(fieldOptions.sizes, formData.size_cat).map((size) => (
- handleSelect("size_cat", size.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") {
- handleSelect("size_cat", size.value);
- }
- }}
- >
-
- {size.label}
-
- ))}
-
-
-
-
-
-
-
-
-
-
Categories
-
-
-
-
- {formData.categories?.length
- ? getSelectedCategoryLabels()
- : "Select categories..."}
-
-
-
-
-
-
-
-
- No categories found.
-
- {getSortedCategories().map((category) => (
- handleMultiSelectChange("categories", category.value)}
- >
-
-
-
- {category.label}
-
-
-
- ))}
-
-
-
-
-
-
-
-
- Description
-
-
-
- );
- };
-
return (
Import Templates
-
-
- Create Template
-
-
-
- Create Import Template
-
- Create a new template for importing products. Company and Product Type combination must be unique.
-
-
-
-
-
+
+ setIsCreateOpen(true)}>
+ Create Blank Template
+
+ setIsSearchOpen(true)}>
+ Create from Product
+
+
@@ -1044,35 +352,28 @@ export function TemplateManagement() {
)}
-
{
- if (!open) {
+ {/* Template Form for Create/Edit/Copy */}
+ {
+ setIsCreateOpen(false);
setEditingTemplate(null);
- setFormData({ company: "", product_type: "" });
- }
- setIsEditOpen(open);
- }}>
-
-
- Edit Template
-
- Edit the template details. Company and Product Type combination must be unique.
-
-
-
-
-
+ }}
+ onSuccess={handleTemplateSuccess}
+ initialData={editingTemplate || undefined}
+ mode={editingTemplate ? (editingTemplate.id ? 'edit' : 'copy') : 'create'}
+ templateId={editingTemplate?.id}
+ fieldOptions={fieldOptions}
+ />
+ {/* Product Search Dialog */}
+
setIsSearchOpen(false)}
+ onTemplateCreated={handleTemplateSuccess}
+ />
+
+ {/* Delete Confirmation Dialog */}
diff --git a/inventory/src/components/templates/SearchProductTemplateDialog.tsx b/inventory/src/components/templates/SearchProductTemplateDialog.tsx
new file mode 100644
index 0000000..7bd37bc
--- /dev/null
+++ b/inventory/src/components/templates/SearchProductTemplateDialog.tsx
@@ -0,0 +1,917 @@
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import axios from 'axios';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+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, 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 {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+import { Badge } from "@/components/ui/badge";
+import { TemplateForm } from '@/components/templates/TemplateForm';
+
+interface ProductSearchDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onTemplateCreated: () => void;
+}
+
+interface Product {
+ pid: number;
+ title: string;
+ description: string;
+ sku: string;
+ barcode: string;
+ harmonized_tariff_code: string;
+ price: number;
+ regular_price: number;
+ cost_price: number;
+ vendor: string;
+ vendor_reference: string;
+ notions_reference: string;
+ brand: string;
+ brand_id: string;
+ line: string;
+ line_id: string;
+ subline: string;
+ subline_id: string;
+ artist: string;
+ artist_id: string;
+ moq: number;
+ weight: number;
+ length: number;
+ width: number;
+ height: number;
+ country_of_origin: string;
+ total_sold: number;
+ first_received: string | null;
+ date_last_sold: string | null;
+ supplier?: string;
+ categories?: string[];
+}
+
+interface FieldOption {
+ label: string;
+ value: string;
+ type?: number;
+ level?: number;
+ hexColor?: string;
+}
+
+interface FieldOptions {
+ companies: FieldOption[];
+ artists: FieldOption[];
+ sizes: FieldOption[];
+ themes: FieldOption[];
+ categories: FieldOption[];
+ colors: FieldOption[];
+ suppliers: FieldOption[];
+ taxCategories: FieldOption[];
+ shippingRestrictions: FieldOption[];
+}
+
+interface TemplateFormData {
+ company: string;
+ product_type: string;
+ supplier?: string;
+ msrp?: number;
+ cost_each?: number;
+ qty_per_unit?: number;
+ case_qty?: number;
+ hts_code?: string;
+ description?: string;
+ weight?: number;
+ length?: number;
+ width?: number;
+ height?: number;
+ tax_cat?: string;
+ size_cat?: string;
+ categories?: string[];
+ ship_restrictions?: string;
+}
+
+// Add sorting types
+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;
+}) => {
+ // Use local state to buffer updates
+ const [localTerm, setLocalTerm] = useState(searchTerm);
+
+ // Keep local state in sync with props
+ useEffect(() => {
+ setLocalTerm(searchTerm);
+ }, [searchTerm]);
+
+ // Only update parent state when submission happens
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (localTerm !== searchTerm) {
+ setSearchTerm(localTerm);
+ }
+ handleSearch();
+ };
+
+ // Handle Enter key press
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ if (localTerm !== searchTerm) {
+ setSearchTerm(localTerm);
+ }
+ handleSearch();
+ }
+ };
+
+ return (
+
+ );
+});
+
+// Create a memoized filter component
+const FilterSelects = React.memo(({
+ selectedCompany,
+ setSelectedCompany,
+ selectedDateFilter,
+ setSelectedDateFilter,
+ companies,
+ onFilterChange
+}: {
+ selectedCompany: string;
+ setSelectedCompany: (value: string) => void;
+ selectedDateFilter: string;
+ setSelectedDateFilter: (value: string) => void;
+ companies: FieldOption[];
+ onFilterChange: (type: string, value: string) => void;
+}) => {
+ // Forward these to the parent component's updateSearchParams function
+ const handleCompanyChange = (value: string) => {
+ setSelectedCompany(value);
+ };
+
+ const handleDateFilterChange = (value: string) => {
+ setSelectedDateFilter(value);
+ };
+
+ return (
+
+
+ Filter by Company
+
+
+
+
+
+ Any Company
+ {companies.map((company) => (
+
+ {company.label}
+
+ ))}
+
+
+
+
+
+ Filter by Date
+
+
+
+
+
+ {DATE_FILTER_OPTIONS.map((filter) => (
+
+ {filter.label}
+
+ ))}
+
+
+
+
+ );
+});
+
+// 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 SearchProductTemplateDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) {
+ // Basic component state
+ const [searchResults, setSearchResults] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [step, setStep] = useState<'search' | 'form'>('search');
+ const [selectedProduct, setSelectedProduct] = useState(null);
+ const [fieldOptions, setFieldOptions] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const productsPerPage = 500;
+ const [hasSearched, setHasSearched] = useState(false);
+
+ // Consolidated search parameters state
+ const [searchParams, setSearchParams] = useState({
+ searchTerm: '',
+ company: 'all',
+ dateFilter: 'none'
+ });
+
+ // Sorting states
+ const [sortField, setSortField] = useState(null);
+ const [sortDirection, setSortDirection] = useState(null);
+
+ // Simple helper function to check if any filters are active
+ const hasActiveFilters = () => {
+ return searchParams.company !== 'all' || searchParams.dateFilter !== 'none';
+ };
+
+ // Central function to update search parameters
+ const updateSearchParams = (newParams: Partial, shouldSearch = true) => {
+ // Create updated parameters object
+ const updatedParams = { ...searchParams, ...newParams };
+
+ // First update the state synchronously
+ setSearchParams(updatedParams);
+
+ // If we should trigger a search, do it in the next microtask
+ // to ensure state updates have been processed
+ if (shouldSearch) {
+ // Use a local copy of the params to ensure consistency
+ queueMicrotask(() => {
+ performSearch(updatedParams);
+ });
+ }
+ };
+
+ // Core search function
+ const performSearch = async (params: typeof searchParams = searchParams) => {
+ setIsLoading(true);
+
+ // Use the provided params or the current state
+ const searchParamsToUse = params || searchParams;
+
+ const hasSearchTerm = searchParamsToUse.searchTerm.trim().length > 0;
+ const hasFilters = searchParamsToUse.company !== 'all' || searchParamsToUse.dateFilter !== 'none';
+
+ // Update search state
+ setHasSearched(hasSearchTerm || hasFilters);
+
+ // If no search parameters, reset results
+ if (!hasSearchTerm && !hasFilters) {
+ setSearchResults([]);
+ setHasSearched(false);
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ const apiParams: Record = {};
+
+ // Add search term if it exists
+ if (hasSearchTerm) {
+ apiParams.q = searchParamsToUse.searchTerm.trim();
+ }
+
+ // Add filters if selected
+ if (searchParamsToUse.company !== 'all') {
+ apiParams.company = searchParamsToUse.company;
+ }
+
+ if (searchParamsToUse.dateFilter !== 'none') {
+ apiParams.dateRange = searchParamsToUse.dateFilter;
+ }
+
+ // If we only have filters (no search term), use wildcard search
+ if (!hasSearchTerm && hasFilters) {
+ apiParams.q = '*';
+ }
+
+ const response = await axios.get('/api/import/search-products', { params: apiParams });
+
+ 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,
+ })));
+ } 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);
+ }
+ };
+
+ // Handler functions
+ const handleSearch = () => {
+ performSearch();
+ };
+
+ const clearSearch = () => {
+ updateSearchParams({ searchTerm: '' });
+ };
+
+ const clearFilters = () => {
+ // One call to updateSearchParams with all filter changes
+ // This ensures all filters are cleared in a single atomic update
+ updateSearchParams({
+ company: 'all',
+ dateFilter: 'none'
+ });
+ };
+
+ // Reset all search state when dialog is closed
+ useEffect(() => {
+ if (!isOpen) {
+ setSearchParams({
+ searchTerm: '',
+ company: 'all',
+ dateFilter: 'none'
+ });
+ setSearchResults([]);
+ setSortField(null);
+ setSortDirection(null);
+ setCurrentPage(1);
+ setStep('search');
+ setHasSearched(false);
+ setSelectedProduct(null);
+ } else {
+ // Fetch field options when dialog opens
+ fetchFieldOptions();
+ }
+ }, [isOpen]);
+
+ // Fetch field options when component mounts
+ useEffect(() => {
+ if (isOpen) {
+ fetchFieldOptions();
+ }
+ }, [isOpen]);
+
+ const fetchFieldOptions = async () => {
+ try {
+ const response = await axios.get('/api/import/field-options');
+ setFieldOptions(response.data);
+ } catch (error) {
+ console.error('Error fetching field options:', error);
+ toast.error('Failed to fetch field options', {
+ description: 'Could not retrieve field options from the server'
+ });
+ }
+ };
+
+ const handleProductSelect = async (product: Product) => {
+ setSelectedProduct(product);
+
+ // Try to find a matching company ID
+ let companyId = '';
+ if (product.brand_id && fieldOptions?.companies) {
+ // Attempt to match by brand ID
+ const companyMatch = fieldOptions.companies.find(c => c.value === product.brand_id);
+ if (companyMatch) {
+ companyId = companyMatch.value;
+ }
+ }
+
+ setStep('form');
+ };
+
+ // Get current products for pagination
+ const totalPages = Math.ceil(searchResults.length / productsPerPage);
+
+ // Change page
+ const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
+
+ // Generate page numbers for pagination
+ const getPageNumbers = () => {
+ const pageNumbers = [];
+ const maxPagesToShow = 5;
+
+ if (totalPages <= maxPagesToShow) {
+ // If we have fewer pages than the max to show, display all pages
+ for (let i = 1; i <= totalPages; i++) {
+ pageNumbers.push(i);
+ }
+ } else {
+ // Always include first page
+ pageNumbers.push(1);
+
+ // Calculate start and end of middle pages
+ let startPage = Math.max(2, currentPage - 1);
+ let endPage = Math.min(totalPages - 1, currentPage + 1);
+
+ // Adjust if we're near the beginning
+ if (currentPage <= 3) {
+ endPage = Math.min(totalPages - 1, 4);
+ }
+
+ // Adjust if we're near the end
+ if (currentPage >= totalPages - 2) {
+ startPage = Math.max(2, totalPages - 3);
+ }
+
+ // Add ellipsis after first page if needed
+ if (startPage > 2) {
+ pageNumbers.push('ellipsis-start');
+ }
+
+ // Add middle pages
+ for (let i = startPage; i <= endPage; i++) {
+ pageNumbers.push(i);
+ }
+
+ // Add ellipsis before last page if needed
+ if (endPage < totalPages - 1) {
+ pageNumbers.push('ellipsis-end');
+ }
+
+ // Always include last page
+ pageNumbers.push(totalPages);
+ }
+
+ return pageNumbers;
+ };
+
+ // Sort function
+ const toggleSort = (field: SortField) => {
+ if (sortField === field) {
+ // Toggle direction if already sorting by this field
+ if (sortDirection === 'asc') {
+ setSortDirection('desc');
+ } else if (sortDirection === 'desc') {
+ setSortField(null);
+ setSortDirection(null);
+ }
+ } else {
+ // Set new sort field and direction
+ setSortField(field);
+ setSortDirection('asc');
+ }
+
+ // Reset to page 1 when sorting changes
+ setCurrentPage(1);
+ };
+
+ // Apply sorting to results
+ const getSortedResults = (results: Product[]) => {
+ if (!sortField || !sortDirection) return results;
+
+ return [...results].sort((a, b) => {
+ let valueA: any;
+ let valueB: any;
+
+ // Extract the correct field values
+ switch (sortField) {
+ case 'title':
+ valueA = a.title?.toLowerCase() || '';
+ valueB = b.title?.toLowerCase() || '';
+ break;
+ case 'brand':
+ valueA = a.brand?.toLowerCase() || '';
+ valueB = b.brand?.toLowerCase() || '';
+ break;
+ case 'line':
+ valueA = a.line?.toLowerCase() || '';
+ valueB = b.line?.toLowerCase() || '';
+ break;
+ case 'price':
+ valueA = typeof a.price === 'number' ? a.price : parseFloat(String(a.price) || '0');
+ valueB = typeof b.price === 'number' ? b.price : parseFloat(String(b.price) || '0');
+ break;
+ case 'total_sold':
+ valueA = typeof a.total_sold === 'number' ? a.total_sold : parseInt(String(a.total_sold) || '0', 10);
+ valueB = typeof b.total_sold === 'number' ? b.total_sold : parseInt(String(b.total_sold) || '0', 10);
+ break;
+ case 'first_received':
+ valueA = a.first_received ? new Date(a.first_received).getTime() : 0;
+ valueB = b.first_received ? new Date(b.first_received).getTime() : 0;
+ break;
+ case 'date_last_sold':
+ valueA = a.date_last_sold ? new Date(a.date_last_sold).getTime() : 0;
+ valueB = b.date_last_sold ? new Date(b.date_last_sold).getTime() : 0;
+ break;
+ default:
+ return 0;
+ }
+
+ // Compare the values
+ if (valueA < valueB) {
+ return sortDirection === 'asc' ? -1 : 1;
+ }
+ if (valueA > valueB) {
+ return sortDirection === 'asc' ? 1 : -1;
+ }
+ return 0;
+ });
+ };
+
+ // Update getFilteredResults to remove redundant company filtering
+ const getFilteredResults = () => {
+ if (!searchResults) return [];
+ return searchResults;
+ };
+
+ const filteredResults = getFilteredResults();
+ const sortedResults = getSortedResults(filteredResults);
+
+ // Get current products for pagination
+ const indexOfLastProductFiltered = currentPage * productsPerPage;
+ const indexOfFirstProductFiltered = indexOfLastProductFiltered - productsPerPage;
+ const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered);
+ const totalPagesFiltered = Math.ceil(sortedResults.length / productsPerPage);
+
+ // Get sort icon
+ const getSortIcon = (field: SortField) => {
+ if (sortField !== field) {
+ return ;
+ }
+
+ return sortDirection === 'asc'
+ ?
+ : ;
+ };
+
+ // Sortable table header component
+ const SortableTableHead = ({ field, children }: { field: SortField, children: React.ReactNode }) => (
+ toggleSort(field)}
+ >
+
+ {children}
+ {getSortIcon(field)}
+
+
+ );
+
+ return (
+ !open && onClose()}>
+ 0
+ ? 'max-w-5xl'
+ : 'max-w-2xl'
+ : 'max-w-2xl'
+ }`}>
+ {step === 'search' ? (
+ <>
+
+ Search Products
+
+ Search for a product you want to use as a template.
+
+
+
+
+
+
updateSearchParams({ searchTerm: term }, false)}
+ handleSearch={handleSearch}
+ isLoading={isLoading}
+ onClear={clearSearch}
+ />
+
+ updateSearchParams({ company: value })}
+ selectedDateFilter={searchParams.dateFilter}
+ setSelectedDateFilter={(value) => updateSearchParams({ dateFilter: value })}
+ companies={fieldOptions?.companies || []}
+ onFilterChange={() => {}}
+ />
+
+ {fieldOptions && hasActiveFilters() && (
+
+
Active filters:
+
+ {searchParams.company !== 'all' && (
+
+ Company: {fieldOptions?.companies?.find(c => c.value === searchParams.company)?.label || 'Unknown'}
+ {
+ updateSearchParams({ company: 'all' });
+ }}
+ >
+
+
+
+ )}
+ {searchParams.dateFilter !== 'none' && (
+
+ Date: {(() => {
+ const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === searchParams.dateFilter);
+ return selectedOption ? selectedOption.label : 'Custom range';
+ })()}
+ {
+ updateSearchParams({ dateFilter: 'none' });
+ }}
+ >
+
+
+
+ )}
+
+ Clear All
+
+
+
+ )}
+
+
+
+
+ {isLoading ? (
+
+
+ Searching...
+
+ ) : !hasSearched ? (
+
+ Use the search field or filters to find products
+
+ ) : searchResults.length === 0 ? (
+
+ No products found matching your criteria
+
+ ) : (
+ <>
+
+ {sortedResults.length} products found
+
+
+
+
+
+ Name
+ {searchParams.company === 'all' && (
+ Company
+ )}
+ Line
+ Price
+ Total Sold
+ Date In
+ Last Sold
+
+
+
+ {currentProductsFiltered.map((product) => (
+ handleProductSelect(product)}
+ >
+ {product.title}
+ {searchParams.company === 'all' && (
+ {product.brand || '-'}
+ )}
+ {product.line || '-'}
+
+ {product.price !== null && product.price !== undefined
+ ? `$${typeof product.price === 'number'
+ ? product.price.toFixed(2)
+ : parseFloat(String(product.price)).toFixed(2)}`
+ : '-'}
+
+
+ {product.total_sold !== null && product.total_sold !== undefined
+ ? typeof product.total_sold === 'number'
+ ? product.total_sold
+ : parseInt(String(product.total_sold), 10)
+ : 0}
+
+
+ {product.first_received
+ ? (() => {
+ try {
+ return new Date(product.first_received).toLocaleDateString();
+ } catch (e) {
+ console.error('Error formatting first_received date:', e);
+ return '-';
+ }
+ })()
+ : '-'}
+
+
+ {product.date_last_sold
+ ? (() => {
+ try {
+ return new Date(product.date_last_sold).toLocaleDateString();
+ } catch (e) {
+ console.error('Error formatting date_last_sold date:', e);
+ return '-';
+ }
+ })()
+ : '-'}
+
+
+ ))}
+
+
+
+
+ {totalPagesFiltered > 1 && (
+
+
+
+
+ currentPage > 1 && paginate(currentPage - 1)}
+ className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+
+
+ {getPageNumbers().map((page, index) => (
+
+ {page === 'ellipsis-start' || page === 'ellipsis-end' ? (
+
+ ) : (
+ typeof page === 'number' && paginate(page)}
+ className={typeof page === 'number' ? "cursor-pointer" : ""}
+ >
+ {page}
+
+ )}
+
+ ))}
+
+
+ currentPage < totalPagesFiltered && paginate(currentPage + 1)}
+ className={currentPage === totalPagesFiltered ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ Cancel
+
+ >
+ ) : (
+ setStep('search')}
+ onSuccess={onTemplateCreated}
+ initialData={selectedProduct ? {
+ company: selectedProduct.brand_id,
+ product_type: '',
+ supplier: selectedProduct.supplier,
+ msrp: selectedProduct.regular_price ? Number(Number(selectedProduct.regular_price).toFixed(2)) : undefined,
+ cost_each: selectedProduct.cost_price ? Number(Number(selectedProduct.cost_price).toFixed(2)) : undefined,
+ qty_per_unit: selectedProduct.moq ? Number(selectedProduct.moq) : undefined,
+ hts_code: selectedProduct.harmonized_tariff_code || undefined,
+ description: selectedProduct.description || undefined,
+ weight: selectedProduct.weight ? Number(Number(selectedProduct.weight).toFixed(2)) : undefined,
+ length: selectedProduct.length ? Number(Number(selectedProduct.length).toFixed(2)) : undefined,
+ width: selectedProduct.width ? Number(Number(selectedProduct.width).toFixed(2)) : undefined,
+ height: selectedProduct.height ? Number(Number(selectedProduct.height).toFixed(2)) : undefined,
+ categories: selectedProduct.categories || [],
+ } : undefined}
+ mode="create"
+ fieldOptions={fieldOptions}
+ />
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/inventory/src/components/templates/SearchProductTemplateDialog.tsx.bak b/inventory/src/components/templates/SearchProductTemplateDialog.tsx.bak
new file mode 100644
index 0000000..059bf1d
--- /dev/null
+++ b/inventory/src/components/templates/SearchProductTemplateDialog.tsx.bak
@@ -0,0 +1,914 @@
+import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
+import axios from 'axios';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+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, 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 {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+import { Badge } from "@/components/ui/badge";
+import { TemplateForm } from '@/components/templates/TemplateForm';
+
+interface ProductSearchDialogProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onTemplateCreated: () => void;
+}
+
+interface Product {
+ pid: number;
+ title: string;
+ description: string;
+ sku: string;
+ barcode: string;
+ harmonized_tariff_code: string;
+ price: number;
+ regular_price: number;
+ cost_price: number;
+ vendor: string;
+ vendor_reference: string;
+ notions_reference: string;
+ brand: string;
+ brand_id: string;
+ line: string;
+ line_id: string;
+ subline: string;
+ subline_id: string;
+ artist: string;
+ artist_id: string;
+ moq: number;
+ weight: number;
+ length: number;
+ width: number;
+ height: number;
+ country_of_origin: string;
+ total_sold: number;
+ first_received: string | null;
+ date_last_sold: string | null;
+ supplier?: string;
+ categories?: string[];
+}
+
+interface FieldOption {
+ label: string;
+ value: string;
+ type?: number;
+ level?: number;
+ hexColor?: string;
+}
+
+interface FieldOptions {
+ companies: FieldOption[];
+ artists: FieldOption[];
+ sizes: FieldOption[];
+ themes: FieldOption[];
+ categories: FieldOption[];
+ colors: FieldOption[];
+ suppliers: FieldOption[];
+ taxCategories: FieldOption[];
+ shippingRestrictions: FieldOption[];
+}
+
+interface TemplateFormData {
+ company: string;
+ product_type: string;
+ supplier?: string;
+ msrp?: number;
+ cost_each?: number;
+ qty_per_unit?: number;
+ case_qty?: number;
+ hts_code?: string;
+ description?: string;
+ weight?: number;
+ length?: number;
+ width?: number;
+ height?: number;
+ tax_cat?: string;
+ size_cat?: string;
+ categories?: string[];
+ ship_restrictions?: string;
+}
+
+// Add sorting types
+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;
+}) => (
+
+));
+
+// Create a memoized filter component
+const FilterSelects = React.memo(({
+ selectedCompany,
+ selectedDateFilter,
+ companies
+}: {
+ selectedCompany: string;
+ selectedDateFilter: string;
+ companies: FieldOption[];
+}) => (
+
+
+ Filter by Company
+ handleFilterChange('company', value)}
+ >
+
+
+
+
+ Any Company
+ {companies.map((company) => (
+
+ {company.label}
+
+ ))}
+
+
+
+
+
+ Filter by Date
+ handleFilterChange('dateFilter', value)}
+ >
+
+
+
+
+ {DATE_FILTER_OPTIONS.map((filter) => (
+
+ {filter.label}
+
+ ))}
+
+
+
+
+ ));
+
+// 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 SearchProductTemplateDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) {
+ // Product search state
+ const [searchTerm, setSearchTerm] = useState('');
+ const [searchResults, setSearchResults] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [step, setStep] = useState<'search' | 'form'>('search');
+ const [selectedProduct, setSelectedProduct] = useState(null);
+ const [fieldOptions, setFieldOptions] = useState(null);
+ const [currentPage, setCurrentPage] = useState(1);
+ const productsPerPage = 500;
+
+ // Filter states - using a single object for filters to ensure atomic updates
+ const [filters, setFilters] = useState({
+ company: 'all',
+ dateFilter: 'none'
+ });
+
+ // Sorting states
+ const [sortField, setSortField] = useState(null);
+ const [sortDirection, setSortDirection] = useState(null);
+
+ const [hasSearched, setHasSearched] = useState(false);
+
+ // Simplified function to check if any filters are active
+ const hasActiveFilters = useCallback(() => {
+ return filters.company !== 'all' || filters.dateFilter !== 'none';
+ }, [filters]);
+
+ // Simplified search state change handler
+ const handleSearchStateChange = useCallback(async (searchTermToUse = searchTerm) => {
+ console.log('Search state change with:', { searchTermToUse, filters });
+ setIsLoading(true);
+
+ const hasSearchTerm = searchTermToUse.trim().length > 0;
+ const hasFilters = hasActiveFilters();
+ setHasSearched(hasSearchTerm || hasFilters);
+
+ // If no active filters or search term, reset everything
+ if (!hasSearchTerm && !hasFilters) {
+ console.log('No search term or filters, resetting results');
+ setSearchResults([]);
+ setHasSearched(false);
+ setIsLoading(false);
+ return;
+ }
+
+ try {
+ const params: Record = {};
+
+ // Add search term if it exists
+ if (hasSearchTerm) {
+ params.q = searchTermToUse.trim();
+ }
+
+ // Add company filter if selected
+ if (filters.company !== 'all') {
+ params.company = filters.company;
+ }
+
+ // Add date filter if selected
+ if (filters.dateFilter !== 'none') {
+ params.dateRange = filters.dateFilter;
+ }
+
+ // If we only have filters (no search term), use wildcard search
+ if (!hasSearchTerm && hasFilters) {
+ params.q = '*';
+ }
+
+ console.log('Search params:', params);
+
+ const response = await axios.get('/api/import/search-products', { params });
+ console.log('Search response:', response.data);
+
+ 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,
+ })));
+ } 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);
+ }
+ }, [hasActiveFilters, filters, searchTerm]);
+
+ // Trigger search when dialog opens with active filters
+ useEffect(() => {
+ if (isOpen && hasSearched) {
+ handleSearchStateChange();
+ }
+ }, [isOpen, hasSearched, handleSearchStateChange]);
+
+ // Simplified handler functions
+ const handleSearch = useCallback(() => {
+ handleSearchStateChange();
+ }, [handleSearchStateChange]);
+
+ const clearSearch = useCallback(() => {
+ setSearchTerm('');
+ handleSearchStateChange('');
+ }, [handleSearchStateChange]);
+
+ const clearFilters = useCallback(() => {
+ setFilters({
+ company: 'all',
+ dateFilter: 'none'
+ });
+ // Give UI time to update
+ setTimeout(() => {
+ handleSearchStateChange();
+ }, 0);
+ }, [handleSearchStateChange]);
+
+ // Handler for filter changes
+ const handleFilterChange = useCallback((type: 'company' | 'dateFilter', value: string) => {
+ setFilters(prev => {
+ const newFilters = { ...prev, [type]: value };
+ console.log(`Filter ${type} changed to ${value}`, newFilters);
+
+ // Schedule search with updated filters
+ setTimeout(() => {
+ handleSearchStateChange();
+ }, 0);
+
+ return newFilters;
+ });
+ }, [handleSearchStateChange]);
+
+ // Reset all search state when dialog is closed
+ useEffect(() => {
+ if (!isOpen) {
+ setSearchTerm('');
+ setSearchResults([]);
+ setFilters({
+ company: 'all',
+ dateFilter: 'none'
+ });
+ setSortField(null);
+ setSortDirection(null);
+ setCurrentPage(1);
+ setStep('search');
+ setHasSearched(false);
+ setSelectedProduct(null);
+ } else {
+ // Fetch field options when dialog opens
+ fetchFieldOptions();
+ }
+ }, [isOpen]);
+
+ // Fetch field options when component mounts
+ const fetchFieldOptions = async () => {
+ try {
+ const response = await axios.get('/api/import/field-options');
+ setFieldOptions(response.data);
+ } catch (error) {
+ console.error('Error fetching field options:', error);
+ toast.error('Failed to fetch field options', {
+ description: 'Could not retrieve field options from the server'
+ });
+ }
+ };
+
+ const handleProductSelect = async (product: Product) => {
+ console.log('Selected product:', product);
+ console.log('Brand ID:', product.brand_id);
+
+ // Try to find the supplier ID from the vendor name
+ let supplierValue;
+ if (product.vendor && fieldOptions) {
+ let supplierOption = fieldOptions.suppliers.find(
+ supplier => supplier.label.toLowerCase() === product.vendor.toLowerCase()
+ );
+
+ // If no exact match, try partial match
+ if (!supplierOption) {
+ supplierOption = fieldOptions.suppliers.find(
+ supplier => supplier.label.toLowerCase().includes(product.vendor.toLowerCase()) ||
+ product.vendor.toLowerCase().includes(supplier.label.toLowerCase())
+ );
+ }
+
+ if (supplierOption) {
+ supplierValue = supplierOption.value;
+ }
+ }
+
+ // Fetch categories for the product
+ let categories: string[] = [];
+ try {
+ const response = await axios.get(`/api/import/product-categories/${product.pid}`);
+ if (response.data && Array.isArray(response.data)) {
+ // Filter out themes and subthemes (types 20 and 21)
+ categories = response.data
+ .filter((category: any) => category.type !== 20 && category.type !== 21)
+ .map((category: any) => category.value);
+ }
+ } catch (error) {
+ console.error('Error fetching product categories:', error);
+ }
+
+ // Ensure brand_id is properly set
+ const companyId = product.brand_id || '';
+ console.log('Setting company ID:', companyId);
+
+ const selectedProduct = {
+ ...product,
+ brand_id: companyId, // Ensure brand_id is set
+ supplier: supplierValue,
+ categories: categories || []
+ };
+
+ console.log('Setting selected product:', selectedProduct);
+ setSelectedProduct(selectedProduct);
+ setStep('form');
+ };
+
+ // Get current products for pagination
+ const totalPages = Math.ceil(searchResults.length / productsPerPage);
+
+ // Change page
+ const paginate = (pageNumber: number) => setCurrentPage(pageNumber);
+
+ // Generate page numbers for pagination
+ const getPageNumbers = () => {
+ const pageNumbers = [];
+ const maxPagesToShow = 5;
+
+ if (totalPages <= maxPagesToShow) {
+ // If we have fewer pages than the max to show, display all pages
+ for (let i = 1; i <= totalPages; i++) {
+ pageNumbers.push(i);
+ }
+ } else {
+ // Always include first page
+ pageNumbers.push(1);
+
+ // Calculate start and end of middle pages
+ let startPage = Math.max(2, currentPage - 1);
+ let endPage = Math.min(totalPages - 1, currentPage + 1);
+
+ // Adjust if we're near the beginning
+ if (currentPage <= 3) {
+ endPage = Math.min(totalPages - 1, 4);
+ }
+
+ // Adjust if we're near the end
+ if (currentPage >= totalPages - 2) {
+ startPage = Math.max(2, totalPages - 3);
+ }
+
+ // Add ellipsis after first page if needed
+ if (startPage > 2) {
+ pageNumbers.push('ellipsis-start');
+ }
+
+ // Add middle pages
+ for (let i = startPage; i <= endPage; i++) {
+ pageNumbers.push(i);
+ }
+
+ // Add ellipsis before last page if needed
+ if (endPage < totalPages - 1) {
+ pageNumbers.push('ellipsis-end');
+ }
+
+ // Always include last page
+ pageNumbers.push(totalPages);
+ }
+
+ return pageNumbers;
+ };
+
+ // Sort function
+ const toggleSort = (field: SortField) => {
+ if (sortField === field) {
+ // Toggle direction if already sorting by this field
+ if (sortDirection === 'asc') {
+ setSortDirection('desc');
+ } else if (sortDirection === 'desc') {
+ setSortField(null);
+ setSortDirection(null);
+ }
+ } else {
+ // Set new sort field and direction
+ setSortField(field);
+ setSortDirection('asc');
+ }
+
+ // Reset to page 1 when sorting changes
+ setCurrentPage(1);
+ };
+
+ // Apply sorting to results
+ const getSortedResults = (results: Product[]) => {
+ if (!sortField || !sortDirection) return results;
+
+ return [...results].sort((a, b) => {
+ let valueA: any;
+ let valueB: any;
+
+ // Extract the correct field values
+ switch (sortField) {
+ case 'title':
+ valueA = a.title?.toLowerCase() || '';
+ valueB = b.title?.toLowerCase() || '';
+ break;
+ case 'brand':
+ valueA = a.brand?.toLowerCase() || '';
+ valueB = b.brand?.toLowerCase() || '';
+ break;
+ case 'line':
+ valueA = a.line?.toLowerCase() || '';
+ valueB = b.line?.toLowerCase() || '';
+ break;
+ case 'price':
+ valueA = typeof a.price === 'number' ? a.price : parseFloat(String(a.price) || '0');
+ valueB = typeof b.price === 'number' ? b.price : parseFloat(String(b.price) || '0');
+ break;
+ case 'total_sold':
+ valueA = typeof a.total_sold === 'number' ? a.total_sold : parseInt(String(a.total_sold) || '0', 10);
+ valueB = typeof b.total_sold === 'number' ? b.total_sold : parseInt(String(b.total_sold) || '0', 10);
+ break;
+ case 'first_received':
+ valueA = a.first_received ? new Date(a.first_received).getTime() : 0;
+ valueB = b.first_received ? new Date(b.first_received).getTime() : 0;
+ break;
+ case 'date_last_sold':
+ valueA = a.date_last_sold ? new Date(a.date_last_sold).getTime() : 0;
+ valueB = b.date_last_sold ? new Date(b.date_last_sold).getTime() : 0;
+ break;
+ default:
+ return 0;
+ }
+
+ // Compare the values
+ if (valueA < valueB) {
+ return sortDirection === 'asc' ? -1 : 1;
+ }
+ if (valueA > valueB) {
+ return sortDirection === 'asc' ? 1 : -1;
+ }
+ return 0;
+ });
+ };
+
+ // Update getFilteredResults to remove redundant company filtering
+ const getFilteredResults = () => {
+ if (!searchResults) return [];
+ return searchResults;
+ };
+
+ const filteredResults = getFilteredResults();
+ const sortedResults = getSortedResults(filteredResults);
+
+ // Get current products for pagination
+ const indexOfLastProductFiltered = currentPage * productsPerPage;
+ const indexOfFirstProductFiltered = indexOfLastProductFiltered - productsPerPage;
+ const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered);
+ const totalPagesFiltered = Math.ceil(sortedResults.length / productsPerPage);
+
+ // Get sort icon
+ const getSortIcon = (field: SortField) => {
+ if (sortField !== field) {
+ return ;
+ }
+
+ return sortDirection === 'asc'
+ ?
+ : ;
+ };
+
+ // Sortable table header component
+ const SortableTableHead = ({ field, children }: { field: SortField, children: React.ReactNode }) => (
+ toggleSort(field)}
+ >
+
+ {children}
+ {getSortIcon(field)}
+
+
+ );
+
+ return (
+ !open && onClose()}>
+ 0
+ ? 'max-w-5xl'
+ : 'max-w-2xl'
+ : 'max-w-2xl'
+ }`}>
+ {step === 'search' ? (
+ <>
+
+ Search Products
+
+ Search for a product you want to use as a template.
+
+
+
+
+
+
+
+
+
+ {fieldOptions && ((filters.company && filters.company !== 'all') || filters.dateFilter !== 'none') && (
+
+
Active filters:
+
+ {filters.company && filters.company !== "all" && (
+
+ Company: {fieldOptions?.companies?.find(c => c.value === filters.company)?.label || 'Unknown'}
+ {
+ setFilters({
+ company: 'all',
+ dateFilter: 'none'
+ });
+ handleSearchStateChange();
+ }}
+ >
+
+
+
+ )}
+ {filters.dateFilter && filters.dateFilter !== 'none' && (
+
+ Date: {(() => {
+ const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === filters.dateFilter);
+ return selectedOption ? selectedOption.label : 'Custom range';
+ })()}
+ {
+ setFilters({
+ company: 'all',
+ dateFilter: 'none'
+ });
+ handleSearchStateChange();
+ }}
+ >
+
+
+
+ )}
+
+ Clear All
+
+
+
+ )}
+
+
+
+
+ {isLoading ? (
+
+
+ Searching...
+
+ ) : !hasSearched ? (
+
+ Use the search field or filters to find products
+
+ ) : searchResults.length === 0 ? (
+
+ No products found matching your criteria
+
+ ) : (
+ <>
+
+ {sortedResults.length} products found
+
+
+
+
+
+ Name
+ {filters.company === 'all' && (
+ Company
+ )}
+ Line
+ Price
+ Total Sold
+ Date In
+ Last Sold
+
+
+
+ {currentProductsFiltered.map((product) => (
+ handleProductSelect(product)}
+ >
+ {product.title}
+ {filters.company === 'all' && (
+ {product.brand || '-'}
+ )}
+ {product.line || '-'}
+
+ {product.price !== null && product.price !== undefined
+ ? `$${typeof product.price === 'number'
+ ? product.price.toFixed(2)
+ : parseFloat(String(product.price)).toFixed(2)}`
+ : '-'}
+
+
+ {product.total_sold !== null && product.total_sold !== undefined
+ ? typeof product.total_sold === 'number'
+ ? product.total_sold
+ : parseInt(String(product.total_sold), 10)
+ : 0}
+
+
+ {product.first_received
+ ? (() => {
+ try {
+ return new Date(product.first_received).toLocaleDateString();
+ } catch (e) {
+ console.error('Error formatting first_received date:', e);
+ return '-';
+ }
+ })()
+ : '-'}
+
+
+ {product.date_last_sold
+ ? (() => {
+ try {
+ return new Date(product.date_last_sold).toLocaleDateString();
+ } catch (e) {
+ console.error('Error formatting date_last_sold date:', e);
+ return '-';
+ }
+ })()
+ : '-'}
+
+
+ ))}
+
+
+
+
+ {totalPagesFiltered > 1 && (
+
+
+
+
+ currentPage > 1 && paginate(currentPage - 1)}
+ className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+
+
+ {getPageNumbers().map((page, index) => (
+
+ {page === 'ellipsis-start' || page === 'ellipsis-end' ? (
+
+ ) : (
+ typeof page === 'number' && paginate(page)}
+ className={typeof page === 'number' ? "cursor-pointer" : ""}
+ >
+ {page}
+
+ )}
+
+ ))}
+
+
+ currentPage < totalPagesFiltered && paginate(currentPage + 1)}
+ className={currentPage === totalPagesFiltered ? "pointer-events-none opacity-50" : "cursor-pointer"}
+ />
+
+
+
+
+ )}
+ >
+ )}
+
+
+
+
+
+ Cancel
+
+ >
+ ) : (
+ setStep('search')}
+ onSuccess={onTemplateCreated}
+ initialData={selectedProduct ? {
+ company: selectedProduct.brand_id,
+ product_type: '',
+ supplier: selectedProduct.supplier,
+ msrp: selectedProduct.regular_price ? Number(Number(selectedProduct.regular_price).toFixed(2)) : undefined,
+ cost_each: selectedProduct.cost_price ? Number(Number(selectedProduct.cost_price).toFixed(2)) : undefined,
+ qty_per_unit: selectedProduct.moq ? Number(selectedProduct.moq) : undefined,
+ hts_code: selectedProduct.harmonized_tariff_code || undefined,
+ description: selectedProduct.description || undefined,
+ weight: selectedProduct.weight ? Number(Number(selectedProduct.weight).toFixed(2)) : undefined,
+ length: selectedProduct.length ? Number(Number(selectedProduct.length).toFixed(2)) : undefined,
+ width: selectedProduct.width ? Number(Number(selectedProduct.width).toFixed(2)) : undefined,
+ height: selectedProduct.height ? Number(Number(selectedProduct.height).toFixed(2)) : undefined,
+ categories: selectedProduct.categories || [],
+ } : undefined}
+ mode="create"
+ fieldOptions={fieldOptions}
+ />
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/inventory/src/components/templates/TemplateForm.tsx b/inventory/src/components/templates/TemplateForm.tsx
new file mode 100644
index 0000000..6d730ce
--- /dev/null
+++ b/inventory/src/components/templates/TemplateForm.tsx
@@ -0,0 +1,706 @@
+import React from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+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, ChevronsUpDown, Check } from 'lucide-react';
+import { toast } from 'sonner';
+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 axios from 'axios';
+
+interface FieldOption {
+ label: string;
+ value: string;
+ type?: number;
+ level?: number;
+ hexColor?: string;
+}
+
+interface FieldOptions {
+ companies: FieldOption[];
+ artists: FieldOption[];
+ sizes: FieldOption[];
+ themes: FieldOption[];
+ categories: FieldOption[];
+ colors: FieldOption[];
+ suppliers: FieldOption[];
+ taxCategories: FieldOption[];
+ shippingRestrictions: FieldOption[];
+}
+
+interface TemplateFormData {
+ company: string;
+ product_type: string;
+ supplier?: string;
+ msrp?: number;
+ cost_each?: number;
+ qty_per_unit?: number;
+ case_qty?: number;
+ hts_code?: string;
+ description?: string;
+ weight?: number;
+ length?: number;
+ width?: number;
+ height?: number;
+ tax_cat?: string;
+ size_cat?: string;
+ categories?: string[];
+ ship_restrictions?: string;
+}
+
+interface TemplateFormProps {
+ isOpen: boolean;
+ onClose: () => void;
+ onSuccess: () => void;
+ initialData?: TemplateFormData;
+ mode: 'create' | 'edit' | 'copy';
+ templateId?: number;
+ fieldOptions: FieldOptions | null;
+}
+
+export function TemplateForm({
+ isOpen,
+ onClose,
+ onSuccess,
+ initialData,
+ mode,
+ templateId,
+ fieldOptions
+}: TemplateFormProps) {
+ const defaultFormData = {
+ company: "",
+ product_type: "",
+ supplier: undefined,
+ msrp: undefined,
+ cost_each: undefined,
+ qty_per_unit: undefined,
+ case_qty: undefined,
+ hts_code: undefined,
+ description: undefined,
+ weight: undefined,
+ length: undefined,
+ width: undefined,
+ height: undefined,
+ tax_cat: undefined,
+ size_cat: undefined,
+ categories: [],
+ ship_restrictions: undefined
+ };
+
+ const [formData, setFormData] = React.useState(defaultFormData);
+ const [isSubmitting, setIsSubmitting] = React.useState(false);
+
+ // Reset form data when dialog opens/closes or mode changes
+ React.useEffect(() => {
+ if (isOpen) {
+ if (initialData) {
+ console.log('Setting initial form data:', initialData);
+ // Format number fields to 2 decimal places if they exist
+ const formattedData = {
+ ...initialData,
+ company: initialData.company || '', // Ensure company is set
+ msrp: initialData.msrp ? Number(Number(initialData.msrp).toFixed(2)) : undefined,
+ cost_each: initialData.cost_each ? Number(Number(initialData.cost_each).toFixed(2)) : undefined,
+ categories: initialData.categories || []
+ };
+ console.log('Formatted form data:', formattedData);
+ setFormData(formattedData);
+ } else {
+ setFormData(defaultFormData);
+ }
+ }
+ }, [isOpen, initialData, mode]);
+
+ const handleInputChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleTextAreaChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ setFormData(prev => ({ ...prev, [name]: value }));
+ };
+
+ const handleMultiSelectChange = (name: string, value: string) => {
+ setFormData(prev => {
+ const currentValues = name === 'categories'
+ ? (prev.categories || [])
+ : [];
+ const valueSet = new Set(currentValues);
+
+ if (valueSet.has(value)) {
+ valueSet.delete(value);
+ } else {
+ valueSet.add(value);
+ }
+
+ return {
+ ...prev,
+ [name]: Array.from(valueSet),
+ };
+ });
+ };
+
+ const handleSelectChange = (name: string, value: string, closePopover = true) => {
+ setFormData(prev => ({ ...prev, [name]: value }));
+ if (closePopover) {
+ // Close the popover by blurring the trigger button
+ const button = document.activeElement as HTMLElement;
+ button?.blur();
+ }
+ };
+
+ const handleNumberInputChange = (e: React.ChangeEvent) => {
+ const { name, value } = e.target;
+ const numValue = value === '' ? undefined : parseFloat(value);
+ setFormData(prev => ({ ...prev, [name]: numValue }));
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setIsSubmitting(true);
+
+ try {
+ console.log('Form data before submission:', formData);
+
+ // Validate required fields
+ if (!formData.company || !formData.product_type) {
+ toast.error('Validation failed', {
+ description: 'Company and Product Type are required fields'
+ });
+ setIsSubmitting(false);
+ return;
+ }
+
+ // Create a clean copy of form data without undefined values
+ const cleanFormData = Object.entries(formData).reduce((acc, [key, value]) => {
+ if (value !== undefined && value !== '') {
+ // Handle arrays and array-like fields
+ if (Array.isArray(value)) {
+ // For categories array, always quote values as they are strings
+ if (key === 'categories') {
+ acc[key] = value.length > 0 ? `{${value.map(v => `"${v}"`).join(',')}}` : '{}';
+ } else {
+ // For other arrays, don't quote numeric values
+ acc[key] = value.length > 0 ? `{${value.join(',')}}` : '{}';
+ }
+ } else if (key === 'ship_restrictions') {
+ // Ensure ship_restrictions is always formatted as a PostgreSQL array
+ acc[key] = `{${value}}`;
+ } else if (key === 'tax_cat' || key === 'supplier') {
+ // Convert these fields to numbers
+ acc[key] = Number(value);
+ } else {
+ acc[key] = value;
+ }
+ }
+ return acc;
+ }, {} as Record);
+
+ console.log('Clean form data:', cleanFormData);
+
+ // Convert numeric strings to numbers
+ const numericFields = ['msrp', 'cost_each', 'qty_per_unit', 'case_qty', 'weight', 'length', 'width', 'height'];
+ numericFields.forEach(field => {
+ if (cleanFormData[field]) {
+ cleanFormData[field] = Number(cleanFormData[field]);
+ }
+ });
+
+ // Ensure company is a string
+ if (cleanFormData.company) {
+ cleanFormData.company = String(cleanFormData.company);
+ }
+
+ const endpoint = mode === 'edit' ? `/api/templates/${templateId}` : '/api/templates';
+ const method = mode === 'edit' ? 'put' : 'post';
+
+ console.log('Sending request to:', endpoint, 'with data:', cleanFormData);
+
+ const response = await axios[method](endpoint, cleanFormData);
+
+ toast.success(
+ mode === 'edit' ? 'Template updated successfully' : 'Template created successfully'
+ );
+
+ onSuccess();
+ onClose();
+ } catch (error: any) {
+ console.error('Error saving template:', error);
+ console.error('Error response:', error.response?.data);
+ toast.error(
+ 'Failed to save template',
+ {
+ description: error.response?.data?.message || error.message || 'An unknown error occurred'
+ }
+ );
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ // Helper function to sort options with selected value at top
+ const getSortedOptions = (options: FieldOption[], selectedValue?: string | string[]) => {
+ return [...options].sort((a, b) => {
+ if (Array.isArray(selectedValue)) {
+ const aSelected = selectedValue.includes(a.value);
+ const bSelected = selectedValue.includes(b.value);
+ if (aSelected && !bSelected) return -1;
+ if (!aSelected && bSelected) return 1;
+ return 0;
+ }
+ if (a.value === selectedValue) return -1;
+ if (b.value === selectedValue) return 1;
+ return 0;
+ });
+ };
+
+ // Helper function to get selected category labels
+ const getSelectedCategoryLabels = () => {
+ if (!formData.categories || !Array.isArray(formData.categories) || !formData.categories.length || !fieldOptions) return "";
+ const labels = formData.categories
+ .map(value => fieldOptions.categories.find(cat => cat.value === value)?.label)
+ .filter(Boolean);
+ return labels.join(", ");
+ };
+
+ // Sort categories with selected ones first and respect levels
+ const getSortedCategories = () => {
+ if (!fieldOptions) return [];
+ const selected = new Set(formData.categories || []);
+
+ // Filter categories to only include types 10, 11, 12, and 13 (proper categories, not themes)
+ const validCategoryTypes = [10, 11, 12, 13];
+ const filteredCategories = fieldOptions.categories.filter(
+ category => category.type && validCategoryTypes.includes(category.type)
+ );
+
+ return [...filteredCategories].sort((a, b) => {
+ const aSelected = selected.has(a.value);
+ const bSelected = selected.has(b.value);
+ if (aSelected && !bSelected) return -1;
+ if (!aSelected && bSelected) return 1;
+ return 0;
+ });
+ };
+
+ // Add wheel event handler for scrolling
+ const handleWheel = (e: React.WheelEvent) => {
+ const commandList = e.currentTarget;
+ commandList.scrollTop += e.deltaY;
+ e.stopPropagation();
+ };
+
+ // Update the CommandList components to include the wheel handler
+ const renderCommandList = (options: FieldOption[], selectedValue: string | undefined, name: string, closeOnSelect = true) => (
+
+ No options found.
+
+ {getSortedOptions(options, selectedValue).map((option) => (
+ {
+ if (name === 'categories') {
+ handleMultiSelectChange(name, option.value);
+ } else {
+ handleSelectChange(name, option.value, closeOnSelect);
+ }
+ }}
+ >
+ {name === 'categories' ? (
+
+
+
+ {option.label}
+
+
+ ) : (
+ <>
+
+ {option.label}
+ >
+ )}
+
+ ))}
+
+
+ );
+
+ // Add logging to company display
+ const getCompanyLabel = (companyId: string | number) => {
+ if (!fieldOptions) return '';
+ const stringId = String(companyId);
+ const company = fieldOptions.companies.find(c => String(c.value) === stringId);
+ console.log('Finding company label for:', companyId, 'Found:', company, 'Available companies:', fieldOptions.companies);
+ return company?.label || stringId;
+ };
+
+ if (!fieldOptions) return null;
+
+ const title = mode === 'edit'
+ ? 'Edit Template'
+ : mode === 'copy'
+ ? 'Copy Template'
+ : initialData
+ ? 'Create Template from Product'
+ : 'Create Template';
+
+ const description = mode === 'edit'
+ ? 'Edit an existing template. Company and Product Type combination must be unique.'
+ : 'Create a new template for importing products. Company and Product Type combination must be unique.';
+
+ return (
+ {
+ if (!open) {
+ setFormData(defaultFormData); // Reset form when closing
+ onClose();
+ }
+ }}>
+
+
+ {title}
+ {description}
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx
index 8c20e60..6c10a0c 100644
--- a/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx
+++ b/inventory/src/lib/react-spreadsheet-import/src/steps/ValidationStepNew/components/ValidationContainer.tsx
@@ -12,6 +12,7 @@ import { useAiValidation } from '../hooks/useAiValidation'
import { AiValidationDialogs } from './AiValidationDialogs'
import config from '@/config'
import { Fields } from '../../../types'
+import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
/**
* ValidationContainer component - the main wrapper for the validation step
@@ -950,7 +951,7 @@ const ValidationContainer = ({
/>
{/* Product Search Dialog */}
- setIsProductSearchDialogOpen(false)}
onTemplateCreated={loadTemplates}