Fix product search dialog for adding templates, pull out component to use independently, add to template management settings page

This commit is contained in:
2025-03-09 13:42:33 -04:00
parent 74454cdc7f
commit 851cc3c4cc
6 changed files with 2869 additions and 949 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback, useMemo } from 'react';
import axios from 'axios'; import axios from 'axios';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -106,6 +106,174 @@ interface TemplateFormData {
type SortDirection = 'asc' | 'desc' | null; type SortDirection = 'asc' | 'desc' | null;
type SortField = 'title' | 'brand' | 'line' | 'price' | 'total_sold' | 'first_received' | 'date_last_sold' | 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;
}) => (
<form onSubmit={(e) => { e.preventDefault(); handleSearch(); }} className="flex items-center space-x-2">
<div className="relative flex-1">
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pr-8"
/>
{searchTerm && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full w-8 p-0"
onClick={onClear}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Button type="submit" disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</Button>
</form>
));
// 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;
}) => (
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="company-filter" className="text-sm">Filter by Company</Label>
<Select
value={selectedCompany}
onValueChange={(value) => {
setSelectedCompany(value);
onFilterChange();
}}
>
<SelectTrigger id="company-filter">
<SelectValue placeholder="Any Company" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Company</SelectItem>
{companies.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="date-filter" className="text-sm">Filter by Date Received</Label>
<Select
value={selectedDateFilter}
onValueChange={(value) => {
setSelectedDateFilter(value);
onFilterChange();
}}
>
<SelectTrigger id="date-filter">
<SelectValue placeholder="Any time" />
</SelectTrigger>
<SelectContent>
{DATE_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
));
// Create a memoized results table component
const ResultsTable = React.memo(({
results,
selectedCompany,
onSelect
}: {
results: any[];
selectedCompany: string;
onSelect: (product: any) => void;
}) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{selectedCompany === 'all' && <TableHead>Company</TableHead>}
<TableHead>Line</TableHead>
<TableHead>Price</TableHead>
<TableHead>Total Sold</TableHead>
<TableHead>Date In</TableHead>
<TableHead>Last Sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelect(product)}
>
<TableCell>{product.title}</TableCell>
{selectedCompany === 'all' && <TableCell>{product.brand || '-'}</TableCell>}
<TableCell>{product.line || '-'}</TableCell>
<TableCell>
{product.price != null ? `$${Number(product.price).toFixed(2)}` : '-'}
</TableCell>
<TableCell>{product.total_sold || 0}</TableCell>
<TableCell>
{product.first_received
? new Date(product.first_received).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>
{product.date_last_sold
? new Date(product.date_last_sold).toLocaleDateString()
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
));
export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) { export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<Product[]>([]); const [searchResults, setSearchResults] = useState<Product[]>([]);
@@ -122,25 +290,84 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
const productsPerPage = 500; const productsPerPage = 500;
// Filter states // Filter states
const [companyFilter, setCompanyFilter] = useState<string>('all'); const [selectedCompany, setSelectedCompany] = useState<string>('all');
const [dateInFilter, setDateInFilter] = useState<string>('none'); const [selectedDateFilter, setSelectedDateFilter] = useState<string>('none');
// Sorting states // Sorting states
const [sortField, setSortField] = useState<SortField>(null); const [sortField, setSortField] = useState<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(null); const [sortDirection, setSortDirection] = useState<SortDirection>(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<string, any> = {};
// 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 // Reset all search state when dialog is closed
useEffect(() => { useEffect(() => {
if (!isOpen) { if (!isOpen) {
// Reset search state // Reset search state
setSearchTerm(''); setSearchTerm('');
setSearchResults([]); setSearchResults([]);
setCompanyFilter('all'); setSelectedCompany('all');
setDateInFilter('none'); setSelectedDateFilter('none');
setSortField(null); setSortField(null);
setSortDirection(null); setSortDirection(null);
setCurrentPage(1); setCurrentPage(1);
setStep('search'); setStep('search');
setHasSearched(false);
} }
}, [isOpen]); }, [isOpen]);
@@ -149,21 +376,18 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
if (isOpen) { if (isOpen) {
fetchFieldOptions(); fetchFieldOptions();
// Perform initial search if any filters are already applied // Perform initial search if any filters are already applied
if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) { if (selectedCompany !== 'all' || selectedDateFilter !== 'none') {
performSearch(); handleSearchStateChange();
} }
} }
}, [isOpen]); }, [isOpen]);
// Reset pagination when filters change and trigger search // Update the useEffect for filter changes to use the new search function
useEffect(() => { useEffect(() => {
setCurrentPage(1); if (selectedCompany !== 'all' || selectedDateFilter !== 'none') {
handleSearchStateChange();
// Trigger search when company filter changes
if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) {
performSearch();
} }
}, [companyFilter, dateInFilter]); }, [selectedCompany, selectedDateFilter]);
// Fetch product lines when company changes // Fetch product lines when company changes
useEffect(() => { useEffect(() => {
@@ -194,78 +418,20 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
} }
}; };
// Common search function for all search scenarios // Update the search input handlers
const performSearch = async () => {
setIsLoading(true);
try {
// Prepare query parameters
const params: Record<string, any> = {};
// 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);
}
};
const handleSearch = () => { const handleSearch = () => {
performSearch(); handleSearchStateChange();
}; };
const handleCompanyFilterChange = (value: string) => { const clearSearch = () => {
setCompanyFilter(value); setSearchTerm('');
// The useEffect will handle the search handleSearchStateChange();
}; };
const handleDateFilterChange = (value: string) => { const clearFilters = () => {
setDateInFilter(value); setSelectedCompany('all');
// The useEffect will handle the search setSelectedDateFilter('none');
handleSearchStateChange();
}; };
const handleProductSelect = (product: Product) => { const handleProductSelect = (product: Product) => {
@@ -337,14 +503,12 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
try { try {
const response = await axios.get(`/api/import/product-categories/${productId}`); const response = await axios.get(`/api/import/product-categories/${productId}`);
if (response.data && Array.isArray(response.data)) { if (response.data && Array.isArray(response.data)) {
// Filter out categories with type 20 (themes) and type 21 (subthemes) // Filter out categories with type 20 (themes) and type 21 (subthemes)
const filteredCategories = response.data.filter((category: any) => const filteredCategories = response.data.filter((category: any) =>
category.type !== 20 && category.type !== 21 category.type !== 20 && category.type !== 21
); );
// Extract category IDs and update form data // Extract category IDs and update form data
const categoryIds = filteredCategories.map((category: any) => category.value); const categoryIds = filteredCategories.map((category: any) => category.value);
setFormData(prev => ({ setFormData(prev => ({
@@ -631,7 +795,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
return searchResults.filter(product => { return searchResults.filter(product => {
// Apply company filter if set // Apply company filter if set
if (companyFilter && companyFilter !== "all" && product.brand_id !== companyFilter) { if (selectedCompany && selectedCompany !== "all" && product.brand_id !== selectedCompany) {
return false; return false;
} }
@@ -651,19 +815,6 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered); const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered);
const totalPagesFiltered = Math.ceil(sortedResults.length / productsPerPage); 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 // Get sort icon
const getSortIcon = (field: SortField) => { const getSortIcon = (field: SortField) => {
if (sortField !== field) { if (sortField !== field) {
@@ -700,125 +851,57 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
<div className="flex-1 overflow-hidden py-4 px-1"> <div className="flex-1 overflow-hidden py-4 px-1">
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center space-x-2"> <SearchInput
<div className="relative flex-1"> searchTerm={searchTerm}
<Input setSearchTerm={setSearchTerm}
placeholder="Search..." handleSearch={handleSearch}
value={searchTerm} isLoading={isLoading}
onChange={(e) => { onClear={clearSearch}
// Just update the search term without triggering search
setSearchTerm(e.target.value);
}}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pr-8"
/> />
{searchTerm && (
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full w-8 p-0"
onClick={() => {
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([]);
}
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Button onClick={handleSearch} disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</Button>
</div>
{fieldOptions ? ( <FilterSelects
<div className="grid grid-cols-2 gap-4"> selectedCompany={selectedCompany}
<div> setSelectedCompany={setSelectedCompany}
<Label htmlFor="company-filter" className="text-sm">Filter by Company</Label> selectedDateFilter={selectedDateFilter}
<Select setSelectedDateFilter={setSelectedDateFilter}
value={companyFilter} companies={fieldOptions?.companies || []}
onValueChange={handleCompanyFilterChange} onFilterChange={handleSearchStateChange}
> />
<SelectTrigger id="company-filter" className="w-full">
<SelectValue placeholder="All Companies" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Companies</SelectItem>
{fieldOptions.companies?.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div> {fieldOptions && ((selectedCompany && selectedCompany !== 'all') || selectedDateFilter !== 'none') && (
<Label htmlFor="date-filter" className="text-sm">Filter by Date Received</Label>
<Select
value={dateInFilter}
onValueChange={handleDateFilterChange}
>
<SelectTrigger id="date-filter" className="w-full">
<SelectValue placeholder="Any time" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Any time</SelectItem>
<SelectItem value="1week">Last week</SelectItem>
<SelectItem value="1month">Last month</SelectItem>
<SelectItem value="2months">Last 2 months</SelectItem>
<SelectItem value="3months">Last 3 months</SelectItem>
<SelectItem value="6months">Last 6 months</SelectItem>
<SelectItem value="1year">Last year</SelectItem>
</SelectContent>
</Select>
</div>
</div>
) : (
<div className="py-2 text-sm text-muted-foreground">Loading filter options...</div>
)}
{fieldOptions && ((companyFilter && companyFilter !== 'all') || dateInFilter !== 'none') && (
<div className="flex gap-2"> <div className="flex gap-2">
<div className="text-sm text-muted-foreground">Active filters:</div> <div className="text-sm text-muted-foreground">Active filters:</div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{companyFilter && companyFilter !== "all" && ( {selectedCompany && selectedCompany !== "all" && (
<Badge variant="secondary" className="flex items-center gap-1"> <Badge variant="secondary" className="flex items-center gap-1">
Company: {fieldOptions?.companies?.find(c => c.value === companyFilter)?.label || 'Unknown'} Company: {fieldOptions?.companies?.find(c => c.value === selectedCompany)?.label || 'Unknown'}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-4 w-4 p-0 ml-1" className="h-4 w-4 p-0 ml-1"
onClick={() => handleCompanyFilterChange('all')} onClick={() => {
setSelectedCompany('all');
handleSearchStateChange();
}}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
</Badge> </Badge>
)} )}
{dateInFilter && dateInFilter !== 'none' && ( {selectedDateFilter && selectedDateFilter !== 'none' && (
<Badge variant="secondary" className="flex items-center gap-1"> <Badge variant="secondary" className="flex items-center gap-1">
Date: {(() => { Date: {(() => {
switch(dateInFilter) { const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === selectedDateFilter);
case '1week': return 'Last week'; return selectedOption ? selectedOption.label : 'Custom range';
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';
}
})()} })()}
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-4 w-4 p-0 ml-1" className="h-4 w-4 p-0 ml-1"
onClick={() => handleDateFilterChange("none")} onClick={() => {
setSelectedDateFilter('none');
handleSearchStateChange();
}}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</Button> </Button>
@@ -840,14 +923,17 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
<div className="mt-4"> <div className="mt-4">
<ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto max-h-[60vh]"> <ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto max-h-[60vh]">
{isLoading ? ( {isLoading ? (
<div className="text-center py-4 text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Searching...</span>
</div>
) : !hasSearched ? (
<div className="text-center py-4 text-muted-foreground"> <div className="text-center py-4 text-muted-foreground">
Searching... Use the search field or filters to find products
</div> </div>
) : searchResults.length === 0 ? ( ) : searchResults.length === 0 ? (
<div className="text-center py-4 text-muted-foreground"> <div className="text-center py-4 text-muted-foreground">
{companyFilter !== 'all' || dateInFilter !== 'none' || searchTerm.trim() No products found matching your criteria
? 'No products found matching your criteria'
: 'Use the search field or filters to find products'}
</div> </div>
) : ( ) : (
<> <>
@@ -859,7 +945,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
<TableHeader className="sticky top-0 bg-background z-10 border-b"> <TableHeader className="sticky top-0 bg-background z-10 border-b">
<TableRow> <TableRow>
<SortableTableHead field="title">Name</SortableTableHead> <SortableTableHead field="title">Name</SortableTableHead>
{(!companyFilter || companyFilter === "all") && ( {selectedCompany === 'all' && (
<SortableTableHead field="brand">Company</SortableTableHead> <SortableTableHead field="brand">Company</SortableTableHead>
)} )}
<SortableTableHead field="line">Line</SortableTableHead> <SortableTableHead field="line">Line</SortableTableHead>
@@ -877,7 +963,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
onClick={() => handleProductSelect(product)} onClick={() => handleProductSelect(product)}
> >
<TableCell className="font-medium">{product.title}</TableCell> <TableCell className="font-medium">{product.title}</TableCell>
{(!companyFilter || companyFilter === "all") && ( {selectedCompany === 'all' && (
<TableCell>{product.brand || '-'}</TableCell> <TableCell>{product.brand || '-'}</TableCell>
)} )}
<TableCell>{product.line || '-'}</TableCell> <TableCell>{product.line || '-'}</TableCell>
@@ -1516,12 +1602,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
}; };
return ( return (
<Dialog open={isOpen} onOpenChange={(open) => { <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
if (!open) {
// When dialog is closed externally (like clicking outside)
onClose();
}
}}>
<DialogContent className={`max-h-[95vh] flex flex-col p-6 ${step === 'search' ? 'max-w-5xl' : 'max-w-2xl'}`}> <DialogContent className={`max-h-[95vh] flex flex-col p-6 ${step === 'search' ? 'max-w-5xl' : 'max-w-2xl'}`}>
{step === 'search' ? renderSearchStep() : renderFormStep()} {step === 'search' ? renderSearchStep() : renderFormStep()}
</DialogContent> </DialogContent>

View File

@@ -58,6 +58,8 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { SearchProductTemplateDialog } from "@/components/templates/SearchProductTemplateDialog";
import { TemplateForm } from "@/components/templates/TemplateForm";
interface FieldOption { interface FieldOption {
label: string; label: string;
@@ -106,14 +108,10 @@ interface TemplateFormData extends Omit<Template, 'id' | 'created_at' | 'updated
export function TemplateManagement() { export function TemplateManagement() {
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false); const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(null); const [templateToDelete, setTemplateToDelete] = useState<Template | null>(null);
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null); const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
const [formData, setFormData] = useState<TemplateFormData>({
company: "",
product_type: "",
});
const [sorting, setSorting] = useState<SortingState>([]); const [sorting, setSorting] = useState<SortingState>([]);
const [searchQuery, setSearchQuery] = 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({ const deleteMutation = useMutation({
mutationFn: async (id: number) => { mutationFn: async (id: number) => {
const response = await fetch(`${config.apiUrl}/templates/${id}`, { 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) => { const handleEdit = (template: Template) => {
setEditingTemplate(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<HTMLInputElement>) => { const handleCopy = (template: Template) => {
const { name, value } = e.target; // Remove id and timestamps for copy operation
setFormData((prev) => ({ const { id, created_at, updated_at, ...templateData } = template;
...prev, // Set as new template with empty product type
[name]: value, setEditingTemplate({
})); ...templateData,
}; product_type: '',
id: 0 // Add a temporary ID to indicate it's a copy
const handleSelectChange = (name: string, value: string) => { } as Template);
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<HTMLInputElement>) => {
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<HTMLTextAreaElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: value,
}));
}; };
const handleDeleteClick = (template: Template) => { const handleDeleteClick = (template: Template) => {
@@ -318,27 +185,8 @@ export function TemplateManagement() {
} }
}; };
const handleCopy = (template: Template) => { const handleTemplateSuccess = () => {
setFormData({ queryClient.invalidateQueries({ queryKey: ["templates"] });
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 columns = useMemo<ColumnDef<Template>[]>(() => [ const columns = useMemo<ColumnDef<Template>[]>(() => [
@@ -437,558 +285,18 @@ export function TemplateManagement() {
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
}); });
const renderFormFields = () => {
if (!fieldOptions) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
);
}
// 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 (
<div className="grid gap-4 py-4 px-1">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">Company*</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.company && "text-muted-foreground"
)}
>
{formData.company
? fieldOptions.companies.find(
(company) => company.value === formData.company
)?.label
: "Select company..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search companies..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No companies found.</CommandEmpty>
<CommandGroup>
{getSortedOptions(fieldOptions.companies, formData.company).map((company) => (
<CommandItem
key={company.value}
value={company.label}
onSelect={() => handleSelect("company", company.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSelect("company", company.value);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.company === company.value
? "opacity-100"
: "opacity-0"
)}
/>
{company.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="product_type">Product Type*</Label>
<Input
id="product_type"
name="product_type"
value={formData.product_type}
onChange={handleInputChange}
required
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="supplier">Supplier</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.supplier && "text-muted-foreground"
)}
>
{formData.supplier
? fieldOptions.suppliers.find(
(supplier) => supplier.value === formData.supplier
)?.label
: "Select supplier..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search suppliers..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No suppliers found.</CommandEmpty>
<CommandGroup>
{getSortedOptions(fieldOptions.suppliers, formData.supplier).map((supplier) => (
<CommandItem
key={supplier.value}
value={supplier.label}
onSelect={() => handleSelect("supplier", supplier.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSelect("supplier", supplier.value);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.supplier === supplier.value
? "opacity-100"
: "opacity-0"
)}
/>
{supplier.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="msrp">MSRP</Label>
<Input
id="msrp"
name="msrp"
type="number"
step="0.01"
value={formData.msrp || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cost_each">Cost Each</Label>
<Input
id="cost_each"
name="cost_each"
type="number"
step="0.01"
value={formData.cost_each || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="qty_per_unit">Quantity per Unit</Label>
<Input
id="qty_per_unit"
name="qty_per_unit"
type="number"
value={formData.qty_per_unit || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="case_qty">Case Quantity</Label>
<Input
id="case_qty"
name="case_qty"
type="number"
value={formData.case_qty || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ship_restrictions">Shipping Restrictions</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.ship_restrictions?.[0] && "text-muted-foreground"
)}
>
{formData.ship_restrictions?.[0]
? fieldOptions.shippingRestrictions.find(
(r) => r.value === formData.ship_restrictions?.[0]
)?.label
: "Select shipping restriction..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search restrictions..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No restrictions found.</CommandEmpty>
<CommandGroup>
{getSortedOptions(fieldOptions.shippingRestrictions, formData.ship_restrictions?.[0]).map((restriction) => (
<CommandItem
key={restriction.value}
value={restriction.label}
onSelect={(value) => {
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);
}
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.ship_restrictions?.[0] === restriction.value
? "opacity-100"
: "opacity-0"
)}
/>
{restriction.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="hts_code">HTS Code</Label>
<Input
id="hts_code"
name="hts_code"
value={formData.hts_code || ""}
onChange={handleInputChange}
/>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="weight">Weight (oz)</Label>
<Input
id="weight"
name="weight"
type="number"
step="0.01"
value={formData.weight || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="length">Length (in)</Label>
<Input
id="length"
name="length"
type="number"
step="0.01"
value={formData.length || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="width">Width (in)</Label>
<Input
id="width"
name="width"
type="number"
step="0.01"
value={formData.width || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height">Height (in)</Label>
<Input
id="height"
name="height"
type="number"
step="0.01"
value={formData.height || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_cat">Tax Category</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.tax_cat && "text-muted-foreground"
)}
>
{formData.tax_cat !== undefined
? fieldOptions.taxCategories.find(
(cat) => cat.value === formData.tax_cat
)?.label
: "Select tax category..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search tax categories..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No tax categories found.</CommandEmpty>
<CommandGroup>
{getSortedOptions(fieldOptions.taxCategories, formData.tax_cat).map((category) => (
<CommandItem
key={category.value}
value={category.value}
onSelect={() => handleSelect("tax_cat", category.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSelect("tax_cat", category.value);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tax_cat === category.value
? "opacity-100"
: "opacity-0"
)}
/>
{category.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="size_cat">Size Category</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.size_cat && "text-muted-foreground"
)}
>
{formData.size_cat
? fieldOptions.sizes.find(
(size) => size.value === formData.size_cat
)?.label
: "Select size category..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search sizes..." />
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No sizes found.</CommandEmpty>
<CommandGroup>
{getSortedOptions(fieldOptions.sizes, formData.size_cat).map((size) => (
<CommandItem
key={size.value}
value={size.value}
onSelect={() => handleSelect("size_cat", size.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
handleSelect("size_cat", size.value);
}
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.size_cat === size.value
? "opacity-100"
: "opacity-0"
)}
/>
{size.label}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="categories">Categories</Label>
<Popover modal={false}>
<PopoverTrigger asChild className="max-w-[calc(800px-3.5rem)]">
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
<span className={cn(
"truncate block flex-1 text-left",
!formData.categories?.length && "text-muted-foreground"
)}>
{formData.categories?.length
? getSelectedCategoryLabels()
: "Select categories..."}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command className="max-h-[200px]">
<CommandInput placeholder="Search categories..." />
<CommandList onWheel={handleWheel}>
<CommandEmpty>No categories found.</CommandEmpty>
<CommandGroup>
{getSortedCategories().map((category) => (
<CommandItem
key={category.value}
value={category.label}
onSelect={() => handleMultiSelectChange("categories", category.value)}
>
<div className="flex items-center w-full">
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
(formData.categories || []).includes(category.value)
? "opacity-100"
: "opacity-0"
)}
/>
<span className={cn(
"truncate",
category.level === 1 && "font-bold"
)}>
{category.label}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
value={formData.description || ""}
onChange={handleTextAreaChange}
/>
</div>
</div>
);
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">Import Templates</h2> <h2 className="text-2xl font-bold">Import Templates</h2>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}> <div className="flex gap-2">
<DialogTrigger asChild> <Button onClick={() => setIsCreateOpen(true)}>
<Button>Create Template</Button> Create Blank Template
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[95vh] flex flex-col p-6">
<DialogHeader className="px-0">
<DialogTitle>Create Import Template</DialogTitle>
<DialogDescription>
Create a new template for importing products. Company and Product Type combination must be unique.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto">
<div className="pr-4">
{renderFormFields()}
</div>
</ScrollArea>
<DialogFooter className="px-0 mt-6">
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Creating..." : "Create Template"}
</Button> </Button>
</DialogFooter> <Button onClick={() => setIsSearchOpen(true)}>
</form> Create from Product
</DialogContent> </Button>
</Dialog> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -1044,35 +352,28 @@ export function TemplateManagement() {
</div> </div>
)} )}
<Dialog open={isEditOpen} onOpenChange={(open) => { {/* Template Form for Create/Edit/Copy */}
if (!open) { <TemplateForm
isOpen={isCreateOpen || (editingTemplate !== null)}
onClose={() => {
setIsCreateOpen(false);
setEditingTemplate(null); setEditingTemplate(null);
setFormData({ company: "", product_type: "" }); }}
} onSuccess={handleTemplateSuccess}
setIsEditOpen(open); initialData={editingTemplate || undefined}
}}> mode={editingTemplate ? (editingTemplate.id ? 'edit' : 'copy') : 'create'}
<DialogContent className="max-w-2xl max-h-[95vh] flex flex-col p-6"> templateId={editingTemplate?.id}
<DialogHeader className="px-0"> fieldOptions={fieldOptions}
<DialogTitle>Edit Template</DialogTitle> />
<DialogDescription>
Edit the template details. Company and Product Type combination must be unique.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<ScrollArea className="flex-1 overflow-y-auto">
<div className="pr-4">
{renderFormFields()}
</div>
</ScrollArea>
<DialogFooter className="px-0 mt-4">
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? "Updating..." : "Update Template"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* Product Search Dialog */}
<SearchProductTemplateDialog
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
onTemplateCreated={handleTemplateSuccess}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}> <AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>

View File

@@ -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<HTMLInputElement>) => {
if (e.key === 'Enter') {
if (localTerm !== searchTerm) {
setSearchTerm(localTerm);
}
handleSearch();
}
};
return (
<form onSubmit={handleSubmit} className="flex items-center space-x-2">
<div className="relative flex-1">
<Input
placeholder="Search..."
value={localTerm}
onChange={(e) => setLocalTerm(e.target.value)}
onKeyDown={handleKeyDown}
className="pr-8"
/>
{localTerm && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full w-8 p-0"
onClick={() => {
setLocalTerm('');
onClear();
}}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Button type="submit" disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</Button>
</form>
);
});
// 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 (
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="company-filter" className="text-sm">Filter by Company</Label>
<Select
value={selectedCompany}
onValueChange={handleCompanyChange}
>
<SelectTrigger id="company-filter">
<SelectValue placeholder="Any Company" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Company</SelectItem>
{companies.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="date-filter" className="text-sm">Filter by Date</Label>
<Select
value={selectedDateFilter}
onValueChange={handleDateFilterChange}
>
<SelectTrigger id="date-filter">
<SelectValue placeholder="Any Time" />
</SelectTrigger>
<SelectContent>
{DATE_FILTER_OPTIONS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
});
// Create a memoized results table component
const ResultsTable = React.memo(({
results,
selectedCompany,
onSelect
}: {
results: any[];
selectedCompany: string;
onSelect: (product: any) => void;
}) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{selectedCompany === 'all' && <TableHead>Company</TableHead>}
<TableHead>Line</TableHead>
<TableHead>Price</TableHead>
<TableHead>Total Sold</TableHead>
<TableHead>Date In</TableHead>
<TableHead>Last Sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelect(product)}
>
<TableCell>{product.title}</TableCell>
{selectedCompany === 'all' && <TableCell>{product.brand || '-'}</TableCell>}
<TableCell>{product.line || '-'}</TableCell>
<TableCell>
{product.price != null ? `$${Number(product.price).toFixed(2)}` : '-'}
</TableCell>
<TableCell>{product.total_sold || 0}</TableCell>
<TableCell>
{product.first_received
? new Date(product.first_received).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>
{product.date_last_sold
? new Date(product.date_last_sold).toLocaleDateString()
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
));
export function SearchProductTemplateDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) {
// Basic component state
const [searchResults, setSearchResults] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<'search' | 'form'>('search');
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(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<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(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<typeof searchParams>, 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<string, any> = {};
// 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 <ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />;
}
return sortDirection === 'asc'
? <ChevronUp className="ml-1 h-3 w-3" />
: <ChevronDown className="ml-1 h-3 w-3" />;
};
// Sortable table header component
const SortableTableHead = ({ field, children }: { field: SortField, children: React.ReactNode }) => (
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => toggleSort(field)}
>
<div className="flex items-center">
{children}
{getSortIcon(field)}
</div>
</TableHead>
);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className={`max-h-[95vh] flex flex-col p-6 ${
step === 'search'
? searchResults.length > 0
? 'max-w-5xl'
: 'max-w-2xl'
: 'max-w-2xl'
}`}>
{step === 'search' ? (
<>
<DialogHeader className="px-0">
<DialogTitle>Search Products</DialogTitle>
<DialogDescription>
Search for a product you want to use as a template.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden py-4 px-1">
<div className="flex flex-col gap-4">
<SearchInput
searchTerm={searchParams.searchTerm}
setSearchTerm={(term) => updateSearchParams({ searchTerm: term }, false)}
handleSearch={handleSearch}
isLoading={isLoading}
onClear={clearSearch}
/>
<FilterSelects
selectedCompany={searchParams.company}
setSelectedCompany={(value) => updateSearchParams({ company: value })}
selectedDateFilter={searchParams.dateFilter}
setSelectedDateFilter={(value) => updateSearchParams({ dateFilter: value })}
companies={fieldOptions?.companies || []}
onFilterChange={() => {}}
/>
{fieldOptions && hasActiveFilters() && (
<div className="flex gap-2">
<div className="text-sm text-muted-foreground">Active filters:</div>
<div className="flex flex-wrap gap-2">
{searchParams.company !== 'all' && (
<Badge variant="secondary" className="flex items-center gap-1">
Company: {fieldOptions?.companies?.find(c => c.value === searchParams.company)?.label || 'Unknown'}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1"
onClick={() => {
updateSearchParams({ company: 'all' });
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
)}
{searchParams.dateFilter !== 'none' && (
<Badge variant="secondary" className="flex items-center gap-1">
Date: {(() => {
const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === searchParams.dateFilter);
return selectedOption ? selectedOption.label : 'Custom range';
})()}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1"
onClick={() => {
updateSearchParams({ dateFilter: 'none' });
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
)}
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={clearFilters}
>
Clear All
</Button>
</div>
</div>
)}
</div>
<div className="mt-4">
<ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto max-h-[60vh]">
{isLoading ? (
<div className="text-center py-4 text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Searching...</span>
</div>
) : !hasSearched ? (
<div className="text-center py-4 text-muted-foreground">
Use the search field or filters to find products
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No products found matching your criteria
</div>
) : (
<>
<div className="text-sm text-muted-foreground mb-2">
{sortedResults.length} products found
</div>
<div className="relative">
<Table>
<TableHeader className="sticky top-0 bg-background z-10 border-b">
<TableRow>
<SortableTableHead field="title">Name</SortableTableHead>
{searchParams.company === 'all' && (
<SortableTableHead field="brand">Company</SortableTableHead>
)}
<SortableTableHead field="line">Line</SortableTableHead>
<SortableTableHead field="price">Price</SortableTableHead>
<SortableTableHead field="total_sold">Total Sold</SortableTableHead>
<SortableTableHead field="first_received">Date In</SortableTableHead>
<SortableTableHead field="date_last_sold">Last Sold</SortableTableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentProductsFiltered.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer"
onClick={() => handleProductSelect(product)}
>
<TableCell className="font-medium">{product.title}</TableCell>
{searchParams.company === 'all' && (
<TableCell>{product.brand || '-'}</TableCell>
)}
<TableCell>{product.line || '-'}</TableCell>
<TableCell>
{product.price !== null && product.price !== undefined
? `$${typeof product.price === 'number'
? product.price.toFixed(2)
: parseFloat(String(product.price)).toFixed(2)}`
: '-'}
</TableCell>
<TableCell>
{product.total_sold !== null && product.total_sold !== undefined
? typeof product.total_sold === 'number'
? product.total_sold
: parseInt(String(product.total_sold), 10)
: 0}
</TableCell>
<TableCell>
{product.first_received
? (() => {
try {
return new Date(product.first_received).toLocaleDateString();
} catch (e) {
console.error('Error formatting first_received date:', e);
return '-';
}
})()
: '-'}
</TableCell>
<TableCell>
{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 '-';
}
})()
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPagesFiltered > 1 && (
<div className="mt-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => currentPage > 1 && paginate(currentPage - 1)}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{getPageNumbers().map((page, index) => (
<PaginationItem key={index}>
{page === 'ellipsis-start' || page === 'ellipsis-end' ? (
<PaginationEllipsis />
) : (
<PaginationLink
isActive={page === currentPage}
onClick={() => typeof page === 'number' && paginate(page)}
className={typeof page === 'number' ? "cursor-pointer" : ""}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => currentPage < totalPagesFiltered && paginate(currentPage + 1)}
className={currentPage === totalPagesFiltered ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</ScrollArea>
</div>
</div>
<DialogFooter className="px-0 mt-6">
<Button variant="outline" onClick={onClose}>Cancel</Button>
</DialogFooter>
</>
) : (
<TemplateForm
isOpen={true}
onClose={() => 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}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -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;
}) => (
<form onSubmit={(e) => { e.preventDefault(); handleSearch(); }} className="flex items-center space-x-2">
<div className="relative flex-1">
<Input
placeholder="Search..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
className="pr-8"
/>
{searchTerm && (
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-full w-8 p-0"
onClick={onClear}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<Button type="submit" disabled={isLoading}>
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
</Button>
</form>
));
// Create a memoized filter component
const FilterSelects = React.memo(({
selectedCompany,
selectedDateFilter,
companies
}: {
selectedCompany: string;
selectedDateFilter: string;
companies: FieldOption[];
}) => (
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="company-filter" className="text-sm">Filter by Company</Label>
<Select
value={selectedCompany}
onValueChange={(value) => handleFilterChange('company', value)}
>
<SelectTrigger id="company-filter">
<SelectValue placeholder="Any Company" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Any Company</SelectItem>
{companies.map((company) => (
<SelectItem key={company.value} value={company.value}>
{company.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="date-filter" className="text-sm">Filter by Date</Label>
<Select
value={selectedDateFilter}
onValueChange={(value) => handleFilterChange('dateFilter', value)}
>
<SelectTrigger id="date-filter">
<SelectValue placeholder="Any Time" />
</SelectTrigger>
<SelectContent>
{DATE_FILTER_OPTIONS.map((filter) => (
<SelectItem key={filter.value} value={filter.value}>
{filter.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
));
// Create a memoized results table component
const ResultsTable = React.memo(({
results,
selectedCompany,
onSelect
}: {
results: any[];
selectedCompany: string;
onSelect: (product: any) => void;
}) => (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
{selectedCompany === 'all' && <TableHead>Company</TableHead>}
<TableHead>Line</TableHead>
<TableHead>Price</TableHead>
<TableHead>Total Sold</TableHead>
<TableHead>Date In</TableHead>
<TableHead>Last Sold</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer hover:bg-muted/50"
onClick={() => onSelect(product)}
>
<TableCell>{product.title}</TableCell>
{selectedCompany === 'all' && <TableCell>{product.brand || '-'}</TableCell>}
<TableCell>{product.line || '-'}</TableCell>
<TableCell>
{product.price != null ? `$${Number(product.price).toFixed(2)}` : '-'}
</TableCell>
<TableCell>{product.total_sold || 0}</TableCell>
<TableCell>
{product.first_received
? new Date(product.first_received).toLocaleDateString()
: '-'}
</TableCell>
<TableCell>
{product.date_last_sold
? new Date(product.date_last_sold).toLocaleDateString()
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
));
export function SearchProductTemplateDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) {
// Product search state
const [searchTerm, setSearchTerm] = useState('');
const [searchResults, setSearchResults] = useState<Product[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [step, setStep] = useState<'search' | 'form'>('search');
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [fieldOptions, setFieldOptions] = useState<FieldOptions | null>(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<SortField>(null);
const [sortDirection, setSortDirection] = useState<SortDirection>(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<string, any> = {};
// 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 <ChevronsUpDown className="ml-1 h-3 w-3 opacity-50" />;
}
return sortDirection === 'asc'
? <ChevronUp className="ml-1 h-3 w-3" />
: <ChevronDown className="ml-1 h-3 w-3" />;
};
// Sortable table header component
const SortableTableHead = ({ field, children }: { field: SortField, children: React.ReactNode }) => (
<TableHead
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => toggleSort(field)}
>
<div className="flex items-center">
{children}
{getSortIcon(field)}
</div>
</TableHead>
);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className={`max-h-[95vh] flex flex-col p-6 ${
step === 'search'
? searchResults.length > 0
? 'max-w-5xl'
: 'max-w-2xl'
: 'max-w-2xl'
}`}>
{step === 'search' ? (
<>
<DialogHeader className="px-0">
<DialogTitle>Search Products</DialogTitle>
<DialogDescription>
Search for a product you want to use as a template.
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden py-4 px-1">
<div className="flex flex-col gap-4">
<SearchInput
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
handleSearch={handleSearch}
isLoading={isLoading}
onClear={clearSearch}
/>
<FilterSelects
selectedCompany={filters.company}
selectedDateFilter={filters.dateFilter}
companies={fieldOptions?.companies || []}
/>
{fieldOptions && ((filters.company && filters.company !== 'all') || filters.dateFilter !== 'none') && (
<div className="flex gap-2">
<div className="text-sm text-muted-foreground">Active filters:</div>
<div className="flex flex-wrap gap-2">
{filters.company && filters.company !== "all" && (
<Badge variant="secondary" className="flex items-center gap-1">
Company: {fieldOptions?.companies?.find(c => c.value === filters.company)?.label || 'Unknown'}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1"
onClick={() => {
setFilters({
company: 'all',
dateFilter: 'none'
});
handleSearchStateChange();
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
)}
{filters.dateFilter && filters.dateFilter !== 'none' && (
<Badge variant="secondary" className="flex items-center gap-1">
Date: {(() => {
const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === filters.dateFilter);
return selectedOption ? selectedOption.label : 'Custom range';
})()}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 ml-1"
onClick={() => {
setFilters({
company: 'all',
dateFilter: 'none'
});
handleSearchStateChange();
}}
>
<X className="h-3 w-3" />
</Button>
</Badge>
)}
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={clearFilters}
>
Clear All
</Button>
</div>
</div>
)}
</div>
<div className="mt-4">
<ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto max-h-[60vh]">
{isLoading ? (
<div className="text-center py-4 text-muted-foreground flex items-center justify-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span>Searching...</span>
</div>
) : !hasSearched ? (
<div className="text-center py-4 text-muted-foreground">
Use the search field or filters to find products
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-4 text-muted-foreground">
No products found matching your criteria
</div>
) : (
<>
<div className="text-sm text-muted-foreground mb-2">
{sortedResults.length} products found
</div>
<div className="relative">
<Table>
<TableHeader className="sticky top-0 bg-background z-10 border-b">
<TableRow>
<SortableTableHead field="title">Name</SortableTableHead>
{filters.company === 'all' && (
<SortableTableHead field="brand">Company</SortableTableHead>
)}
<SortableTableHead field="line">Line</SortableTableHead>
<SortableTableHead field="price">Price</SortableTableHead>
<SortableTableHead field="total_sold">Total Sold</SortableTableHead>
<SortableTableHead field="first_received">Date In</SortableTableHead>
<SortableTableHead field="date_last_sold">Last Sold</SortableTableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentProductsFiltered.map((product) => (
<TableRow
key={product.pid}
className="cursor-pointer"
onClick={() => handleProductSelect(product)}
>
<TableCell className="font-medium">{product.title}</TableCell>
{filters.company === 'all' && (
<TableCell>{product.brand || '-'}</TableCell>
)}
<TableCell>{product.line || '-'}</TableCell>
<TableCell>
{product.price !== null && product.price !== undefined
? `$${typeof product.price === 'number'
? product.price.toFixed(2)
: parseFloat(String(product.price)).toFixed(2)}`
: '-'}
</TableCell>
<TableCell>
{product.total_sold !== null && product.total_sold !== undefined
? typeof product.total_sold === 'number'
? product.total_sold
: parseInt(String(product.total_sold), 10)
: 0}
</TableCell>
<TableCell>
{product.first_received
? (() => {
try {
return new Date(product.first_received).toLocaleDateString();
} catch (e) {
console.error('Error formatting first_received date:', e);
return '-';
}
})()
: '-'}
</TableCell>
<TableCell>
{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 '-';
}
})()
: '-'}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{totalPagesFiltered > 1 && (
<div className="mt-4">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => currentPage > 1 && paginate(currentPage - 1)}
className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{getPageNumbers().map((page, index) => (
<PaginationItem key={index}>
{page === 'ellipsis-start' || page === 'ellipsis-end' ? (
<PaginationEllipsis />
) : (
<PaginationLink
isActive={page === currentPage}
onClick={() => typeof page === 'number' && paginate(page)}
className={typeof page === 'number' ? "cursor-pointer" : ""}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={() => currentPage < totalPagesFiltered && paginate(currentPage + 1)}
className={currentPage === totalPagesFiltered ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</>
)}
</ScrollArea>
</div>
</div>
<DialogFooter className="px-0 mt-6">
<Button variant="outline" onClick={onClose}>Cancel</Button>
</DialogFooter>
</>
) : (
<TemplateForm
isOpen={true}
onClose={() => 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}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -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<TemplateFormData>(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<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
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<HTMLInputElement>) => {
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<string, any>);
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) => (
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup>
{getSortedOptions(options, selectedValue).map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => {
if (name === 'categories') {
handleMultiSelectChange(name, option.value);
} else {
handleSelectChange(name, option.value, closeOnSelect);
}
}}
>
{name === 'categories' ? (
<div className="flex items-center w-full">
<Check
className={cn(
"mr-2 h-4 w-4 flex-shrink-0",
(formData.categories || []).includes(option.value)
? "opacity-100"
: "opacity-0"
)}
/>
<span className={cn(
"truncate",
option.level === 1 && "font-bold"
)}>
{option.label}
</span>
</div>
) : (
<>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedValue === option.value
? "opacity-100"
: "opacity-0"
)}
/>
{option.label}
</>
)}
</CommandItem>
))}
</CommandGroup>
</CommandList>
);
// 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 (
<Dialog open={isOpen} onOpenChange={(open) => {
if (!open) {
setFormData(defaultFormData); // Reset form when closing
onClose();
}
}}>
<DialogContent className="max-w-2xl max-h-[95vh] flex flex-col p-6">
<DialogHeader className="px-0">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
<ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto">
<div className="grid gap-4 py-4 px-1 pr-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="company">Company*</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.company && "text-muted-foreground"
)}
>
{formData.company
? getCompanyLabel(formData.company)
: "Select company..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search companies..." />
{renderCommandList(fieldOptions.companies, formData.company, 'company')}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="product_type">Product Type*</Label>
<Input
id="product_type"
name="product_type"
value={formData.product_type}
onChange={handleInputChange}
placeholder="e.g. 'Ephemera' or '12x12 Paper'"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="supplier">Supplier</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
(!formData.supplier || formData.supplier === "") && "text-muted-foreground"
)}
>
{formData.supplier && formData.supplier !== ""
? fieldOptions.suppliers.find(
supplier => supplier.value === formData.supplier
)?.label || "Select supplier..."
: "Select supplier..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search suppliers..." />
{renderCommandList(fieldOptions.suppliers, formData.supplier, 'supplier')}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="msrp">MSRP</Label>
<Input
id="msrp"
name="msrp"
type="number"
step="0.01"
value={formData.msrp || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="cost_each">Cost Each</Label>
<Input
id="cost_each"
name="cost_each"
type="number"
step="0.01"
value={formData.cost_each || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="qty_per_unit">Quantity per Unit</Label>
<Input
id="qty_per_unit"
name="qty_per_unit"
type="number"
value={formData.qty_per_unit || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="case_qty">Case Quantity</Label>
<Input
id="case_qty"
name="case_qty"
type="number"
value={formData.case_qty || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ship_restrictions">Shipping Restrictions</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.ship_restrictions && "text-muted-foreground"
)}
>
{formData.ship_restrictions
? fieldOptions.shippingRestrictions.find(
(r) => r.value === formData.ship_restrictions
)?.label
: "Select shipping restriction..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search restrictions..." />
{renderCommandList(fieldOptions.shippingRestrictions, formData.ship_restrictions, 'ship_restrictions')}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="hts_code">HTS Code</Label>
<Input
id="hts_code"
name="hts_code"
value={formData.hts_code || ""}
onChange={handleInputChange}
/>
</div>
</div>
<div className="grid grid-cols-4 gap-4">
<div className="space-y-2">
<Label htmlFor="weight">Weight (oz)</Label>
<Input
id="weight"
name="weight"
type="number"
step="0.01"
value={formData.weight || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="length">Length (in)</Label>
<Input
id="length"
name="length"
type="number"
step="0.01"
value={formData.length || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="width">Width (in)</Label>
<Input
id="width"
name="width"
type="number"
step="0.01"
value={formData.width || ""}
onChange={handleNumberInputChange}
/>
</div>
<div className="space-y-2">
<Label htmlFor="height">Height (in)</Label>
<Input
id="height"
name="height"
type="number"
step="0.01"
value={formData.height || ""}
onChange={handleNumberInputChange}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="tax_cat">Tax Category</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
formData.tax_cat === undefined && "text-muted-foreground"
)}
>
{formData.tax_cat !== undefined
? (fieldOptions.taxCategories.find(
(cat) => cat.value === formData.tax_cat
)?.label || "Not Specifically Set")
: "Select tax category..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search tax categories..." />
{renderCommandList(fieldOptions.taxCategories, formData.tax_cat, 'tax_cat')}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="size_cat">Size Category</Label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn(
"w-full justify-between",
!formData.size_cat && "text-muted-foreground"
)}
>
{formData.size_cat
? fieldOptions.sizes.find(
(size) => size.value === formData.size_cat
)?.label
: "Select size category..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
<Command>
<CommandInput placeholder="Search sizes..." />
{renderCommandList(fieldOptions.sizes, formData.size_cat, 'size_cat')}
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="space-y-2 max-w-[610px]">
<Label htmlFor="categories">Categories</Label>
<Popover modal={false}>
<PopoverTrigger asChild className="w-full">
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
<span className={cn(
"truncate block flex-1 text-left",
!formData.categories?.length && "text-muted-foreground"
)}>
{formData.categories?.length
? getSelectedCategoryLabels()
: "Select categories..."}
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<Command className="max-h-[200px]">
<CommandInput placeholder="Search categories..." />
{renderCommandList(getSortedCategories(), undefined, 'categories', false)}
</Command>
</PopoverContent>
</Popover>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
name="description"
value={formData.description || ""}
onChange={handleTextAreaChange}
rows={3}
/>
</div>
</div>
</ScrollArea>
<DialogFooter className="px-0 mt-6">
<Button type="button" variant="outline" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{mode === 'edit' ? 'Update' : 'Create'} Template
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -12,6 +12,7 @@ import { useAiValidation } from '../hooks/useAiValidation'
import { AiValidationDialogs } from './AiValidationDialogs' import { AiValidationDialogs } from './AiValidationDialogs'
import config from '@/config' import config from '@/config'
import { Fields } from '../../../types' import { Fields } from '../../../types'
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
/** /**
* ValidationContainer component - the main wrapper for the validation step * ValidationContainer component - the main wrapper for the validation step
@@ -950,7 +951,7 @@ const ValidationContainer = <T extends string>({
/> />
{/* Product Search Dialog */} {/* Product Search Dialog */}
<ProductSearchDialog <SearchProductTemplateDialog
isOpen={isProductSearchDialogOpen} isOpen={isProductSearchDialogOpen}
onClose={() => setIsProductSearchDialogOpen(false)} onClose={() => setIsProductSearchDialogOpen(false)}
onTemplateCreated={loadTemplates} onTemplateCreated={loadTemplates}