Fix product search dialog for adding templates, pull out component to use independently, add to template management settings page
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import axios from 'axios';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -106,6 +106,174 @@ interface TemplateFormData {
|
||||
type SortDirection = 'asc' | 'desc' | null;
|
||||
type SortField = 'title' | 'brand' | 'line' | 'price' | 'total_sold' | 'first_received' | 'date_last_sold' | null;
|
||||
|
||||
// Date filter options
|
||||
const DATE_FILTER_OPTIONS = [
|
||||
{ label: "Any time", value: "none" },
|
||||
{ label: "Last week", value: "1week" },
|
||||
{ label: "Last month", value: "1month" },
|
||||
{ label: "Last 2 months", value: "2months" },
|
||||
{ label: "Last 3 months", value: "3months" },
|
||||
{ label: "Last 6 months", value: "6months" },
|
||||
{ label: "Last year", value: "1year" }
|
||||
];
|
||||
|
||||
// Create a memoized search component to prevent unnecessary re-renders
|
||||
const SearchInput = React.memo(({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
handleSearch,
|
||||
isLoading,
|
||||
onClear
|
||||
}: {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
handleSearch: () => void;
|
||||
isLoading: boolean;
|
||||
onClear: () => void;
|
||||
}) => (
|
||||
<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) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Product[]>([]);
|
||||
@@ -122,25 +290,84 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
const productsPerPage = 500;
|
||||
|
||||
// Filter states
|
||||
const [companyFilter, setCompanyFilter] = useState<string>('all');
|
||||
const [dateInFilter, setDateInFilter] = useState<string>('none');
|
||||
const [selectedCompany, setSelectedCompany] = useState<string>('all');
|
||||
const [selectedDateFilter, setSelectedDateFilter] = useState<string>('none');
|
||||
|
||||
// Sorting states
|
||||
const [sortField, setSortField] = useState<SortField>(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
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
// Reset search state
|
||||
setSearchTerm('');
|
||||
setSearchResults([]);
|
||||
setCompanyFilter('all');
|
||||
setDateInFilter('none');
|
||||
setSelectedCompany('all');
|
||||
setSelectedDateFilter('none');
|
||||
setSortField(null);
|
||||
setSortDirection(null);
|
||||
setCurrentPage(1);
|
||||
setStep('search');
|
||||
setHasSearched(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
@@ -149,21 +376,18 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
if (isOpen) {
|
||||
fetchFieldOptions();
|
||||
// Perform initial search if any filters are already applied
|
||||
if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) {
|
||||
performSearch();
|
||||
if (selectedCompany !== 'all' || selectedDateFilter !== 'none') {
|
||||
handleSearchStateChange();
|
||||
}
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset pagination when filters change and trigger search
|
||||
// Update the useEffect for filter changes to use the new search function
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
|
||||
// Trigger search when company filter changes
|
||||
if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) {
|
||||
performSearch();
|
||||
if (selectedCompany !== 'all' || selectedDateFilter !== 'none') {
|
||||
handleSearchStateChange();
|
||||
}
|
||||
}, [companyFilter, dateInFilter]);
|
||||
}, [selectedCompany, selectedDateFilter]);
|
||||
|
||||
// Fetch product lines when company changes
|
||||
useEffect(() => {
|
||||
@@ -194,78 +418,20 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
}
|
||||
};
|
||||
|
||||
// Common search function for all search scenarios
|
||||
const performSearch = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Prepare query parameters
|
||||
const params: Record<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);
|
||||
}
|
||||
};
|
||||
|
||||
// Update the search input handlers
|
||||
const handleSearch = () => {
|
||||
performSearch();
|
||||
handleSearchStateChange();
|
||||
};
|
||||
|
||||
const handleCompanyFilterChange = (value: string) => {
|
||||
setCompanyFilter(value);
|
||||
// The useEffect will handle the search
|
||||
const clearSearch = () => {
|
||||
setSearchTerm('');
|
||||
handleSearchStateChange();
|
||||
};
|
||||
|
||||
const handleDateFilterChange = (value: string) => {
|
||||
setDateInFilter(value);
|
||||
// The useEffect will handle the search
|
||||
const clearFilters = () => {
|
||||
setSelectedCompany('all');
|
||||
setSelectedDateFilter('none');
|
||||
handleSearchStateChange();
|
||||
};
|
||||
|
||||
const handleProductSelect = (product: Product) => {
|
||||
@@ -337,14 +503,12 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
try {
|
||||
const response = await axios.get(`/api/import/product-categories/${productId}`);
|
||||
|
||||
|
||||
if (response.data && Array.isArray(response.data)) {
|
||||
// Filter out categories with type 20 (themes) and type 21 (subthemes)
|
||||
const filteredCategories = response.data.filter((category: any) =>
|
||||
category.type !== 20 && category.type !== 21
|
||||
);
|
||||
|
||||
|
||||
// Extract category IDs and update form data
|
||||
const categoryIds = filteredCategories.map((category: any) => category.value);
|
||||
setFormData(prev => ({
|
||||
@@ -631,7 +795,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
|
||||
return searchResults.filter(product => {
|
||||
// Apply company filter if set
|
||||
if (companyFilter && companyFilter !== "all" && product.brand_id !== companyFilter) {
|
||||
if (selectedCompany && selectedCompany !== "all" && product.brand_id !== selectedCompany) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -651,19 +815,6 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered);
|
||||
const totalPagesFiltered = Math.ceil(sortedResults.length / productsPerPage);
|
||||
|
||||
const clearFilters = () => {
|
||||
setCompanyFilter('all');
|
||||
setDateInFilter('none');
|
||||
|
||||
// If search term is empty, clear results completely
|
||||
if (!searchTerm.trim()) {
|
||||
setSearchResults([]);
|
||||
} else {
|
||||
// Otherwise, perform a search with just the search term
|
||||
performSearch();
|
||||
}
|
||||
};
|
||||
|
||||
// Get sort icon
|
||||
const getSortIcon = (field: SortField) => {
|
||||
if (sortField !== field) {
|
||||
@@ -700,125 +851,57 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
|
||||
<div className="flex-1 overflow-hidden py-4 px-1">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
// 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>
|
||||
<SearchInput
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
handleSearch={handleSearch}
|
||||
isLoading={isLoading}
|
||||
onClear={clearSearch}
|
||||
/>
|
||||
|
||||
{fieldOptions ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="company-filter" className="text-sm">Filter by Company</Label>
|
||||
<Select
|
||||
value={companyFilter}
|
||||
onValueChange={handleCompanyFilterChange}
|
||||
>
|
||||
<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>
|
||||
<FilterSelects
|
||||
selectedCompany={selectedCompany}
|
||||
setSelectedCompany={setSelectedCompany}
|
||||
selectedDateFilter={selectedDateFilter}
|
||||
setSelectedDateFilter={setSelectedDateFilter}
|
||||
companies={fieldOptions?.companies || []}
|
||||
onFilterChange={handleSearchStateChange}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<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') && (
|
||||
{fieldOptions && ((selectedCompany && selectedCompany !== 'all') || selectedDateFilter !== 'none') && (
|
||||
<div className="flex gap-2">
|
||||
<div className="text-sm text-muted-foreground">Active filters:</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{companyFilter && companyFilter !== "all" && (
|
||||
{selectedCompany && selectedCompany !== "all" && (
|
||||
<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
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0 ml-1"
|
||||
onClick={() => handleCompanyFilterChange('all')}
|
||||
onClick={() => {
|
||||
setSelectedCompany('all');
|
||||
handleSearchStateChange();
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
)}
|
||||
{dateInFilter && dateInFilter !== 'none' && (
|
||||
{selectedDateFilter && selectedDateFilter !== 'none' && (
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
Date: {(() => {
|
||||
switch(dateInFilter) {
|
||||
case '1week': return 'Last week';
|
||||
case '1month': return 'Last month';
|
||||
case '2months': return 'Last 2 months';
|
||||
case '3months': return 'Last 3 months';
|
||||
case '6months': return 'Last 6 months';
|
||||
case '1year': return 'Last year';
|
||||
default: return 'Custom range';
|
||||
}
|
||||
const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === selectedDateFilter);
|
||||
return selectedOption ? selectedOption.label : 'Custom range';
|
||||
})()}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 p-0 ml-1"
|
||||
onClick={() => handleDateFilterChange("none")}
|
||||
onClick={() => {
|
||||
setSelectedDateFilter('none');
|
||||
handleSearchStateChange();
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
@@ -840,14 +923,17 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
<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">
|
||||
Searching...
|
||||
Use the search field or filters to find products
|
||||
</div>
|
||||
) : searchResults.length === 0 ? (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
{companyFilter !== 'all' || dateInFilter !== 'none' || searchTerm.trim()
|
||||
? 'No products found matching your criteria'
|
||||
: 'Use the search field or filters to find products'}
|
||||
No products found matching your criteria
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -859,7 +945,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
<TableHeader className="sticky top-0 bg-background z-10 border-b">
|
||||
<TableRow>
|
||||
<SortableTableHead field="title">Name</SortableTableHead>
|
||||
{(!companyFilter || companyFilter === "all") && (
|
||||
{selectedCompany === 'all' && (
|
||||
<SortableTableHead field="brand">Company</SortableTableHead>
|
||||
)}
|
||||
<SortableTableHead field="line">Line</SortableTableHead>
|
||||
@@ -877,7 +963,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
onClick={() => handleProductSelect(product)}
|
||||
>
|
||||
<TableCell className="font-medium">{product.title}</TableCell>
|
||||
{(!companyFilter || companyFilter === "all") && (
|
||||
{selectedCompany === 'all' && (
|
||||
<TableCell>{product.brand || '-'}</TableCell>
|
||||
)}
|
||||
<TableCell>{product.line || '-'}</TableCell>
|
||||
@@ -1516,12 +1602,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// When dialog is closed externally (like clicking outside)
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className={`max-h-[95vh] flex flex-col p-6 ${step === 'search' ? 'max-w-5xl' : 'max-w-2xl'}`}>
|
||||
{step === 'search' ? renderSearchStep() : renderFormStep()}
|
||||
</DialogContent>
|
||||
|
||||
@@ -58,6 +58,8 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { SearchProductTemplateDialog } from "@/components/templates/SearchProductTemplateDialog";
|
||||
import { TemplateForm } from "@/components/templates/TemplateForm";
|
||||
|
||||
interface FieldOption {
|
||||
label: string;
|
||||
@@ -106,14 +108,10 @@ interface TemplateFormData extends Omit<Template, 'id' | 'created_at' | 'updated
|
||||
|
||||
export function TemplateManagement() {
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [templateToDelete, setTemplateToDelete] = 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 [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
@@ -141,56 +139,6 @@ export function TemplateManagement() {
|
||||
},
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async (data: TemplateFormData) => {
|
||||
const response = await fetch(`${config.apiUrl}/templates`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to create template");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
setIsCreateOpen(false);
|
||||
setFormData({ company: "", product_type: "" });
|
||||
toast.success("Template created successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to create template");
|
||||
},
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: number; data: TemplateFormData }) => {
|
||||
const response = await fetch(`${config.apiUrl}/templates/${id}`, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to update template");
|
||||
}
|
||||
return response.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
setIsEditOpen(false);
|
||||
setEditingTemplate(null);
|
||||
toast.success("Template updated successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : "Failed to update template");
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const response = await fetch(`${config.apiUrl}/templates/${id}`, {
|
||||
@@ -209,100 +157,19 @@ export function TemplateManagement() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (editingTemplate) {
|
||||
updateMutation.mutate({ id: editingTemplate.id, data: formData });
|
||||
} else {
|
||||
createMutation.mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (template: Template) => {
|
||||
setEditingTemplate(template);
|
||||
setFormData({
|
||||
company: template.company,
|
||||
product_type: template.product_type,
|
||||
supplier: template.supplier,
|
||||
msrp: template.msrp,
|
||||
cost_each: template.cost_each,
|
||||
qty_per_unit: template.qty_per_unit,
|
||||
case_qty: template.case_qty,
|
||||
hts_code: template.hts_code,
|
||||
description: template.description,
|
||||
weight: template.weight,
|
||||
length: template.length,
|
||||
width: template.width,
|
||||
height: template.height,
|
||||
tax_cat: template.tax_cat,
|
||||
size_cat: template.size_cat,
|
||||
categories: template.categories,
|
||||
ship_restrictions: template.ship_restrictions,
|
||||
});
|
||||
setIsEditOpen(true);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSelectChange = (name: string, value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleMultiSelectChange = (name: string, value: string) => {
|
||||
setFormData((prev) => {
|
||||
const currentValues = prev[name as keyof typeof prev] as string[] || [];
|
||||
const valueSet = new Set(currentValues);
|
||||
|
||||
if (valueSet.has(value)) {
|
||||
valueSet.delete(value);
|
||||
} else {
|
||||
valueSet.add(value);
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[name]: Array.from(valueSet),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleNumberInputChange = (e: React.ChangeEvent<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 handleCopy = (template: Template) => {
|
||||
// Remove id and timestamps for copy operation
|
||||
const { id, created_at, updated_at, ...templateData } = template;
|
||||
// Set as new template with empty product type
|
||||
setEditingTemplate({
|
||||
...templateData,
|
||||
product_type: '',
|
||||
id: 0 // Add a temporary ID to indicate it's a copy
|
||||
} as Template);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (template: Template) => {
|
||||
@@ -318,27 +185,8 @@ export function TemplateManagement() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = (template: Template) => {
|
||||
setFormData({
|
||||
company: template.company,
|
||||
product_type: template.product_type,
|
||||
supplier: template.supplier,
|
||||
msrp: template.msrp,
|
||||
cost_each: template.cost_each,
|
||||
qty_per_unit: template.qty_per_unit,
|
||||
case_qty: template.case_qty,
|
||||
hts_code: template.hts_code,
|
||||
description: template.description,
|
||||
weight: template.weight,
|
||||
length: template.length,
|
||||
width: template.width,
|
||||
height: template.height,
|
||||
tax_cat: template.tax_cat,
|
||||
size_cat: template.size_cat,
|
||||
categories: template.categories,
|
||||
ship_restrictions: template.ship_restrictions,
|
||||
});
|
||||
setIsCreateOpen(true);
|
||||
const handleTemplateSuccess = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||
};
|
||||
|
||||
const columns = useMemo<ColumnDef<Template>[]>(() => [
|
||||
@@ -437,558 +285,18 @@ export function TemplateManagement() {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Import Templates</h2>
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>Create Template</Button>
|
||||
</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>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={() => setIsCreateOpen(true)}>
|
||||
Create Blank Template
|
||||
</Button>
|
||||
<Button onClick={() => setIsSearchOpen(true)}>
|
||||
Create from Product
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -1044,35 +352,28 @@ export function TemplateManagement() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={isEditOpen} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
{/* Template Form for Create/Edit/Copy */}
|
||||
<TemplateForm
|
||||
isOpen={isCreateOpen || (editingTemplate !== null)}
|
||||
onClose={() => {
|
||||
setIsCreateOpen(false);
|
||||
setEditingTemplate(null);
|
||||
setFormData({ company: "", product_type: "" });
|
||||
}
|
||||
setIsEditOpen(open);
|
||||
}}>
|
||||
<DialogContent className="max-w-2xl max-h-[95vh] flex flex-col p-6">
|
||||
<DialogHeader className="px-0">
|
||||
<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>
|
||||
}}
|
||||
onSuccess={handleTemplateSuccess}
|
||||
initialData={editingTemplate || undefined}
|
||||
mode={editingTemplate ? (editingTemplate.id ? 'edit' : 'copy') : 'create'}
|
||||
templateId={editingTemplate?.id}
|
||||
fieldOptions={fieldOptions}
|
||||
/>
|
||||
|
||||
{/* Product Search Dialog */}
|
||||
<SearchProductTemplateDialog
|
||||
isOpen={isSearchOpen}
|
||||
onClose={() => setIsSearchOpen(false)}
|
||||
onTemplateCreated={handleTemplateSuccess}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
706
inventory/src/components/templates/TemplateForm.tsx
Normal file
706
inventory/src/components/templates/TemplateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAiValidation } from '../hooks/useAiValidation'
|
||||
import { AiValidationDialogs } from './AiValidationDialogs'
|
||||
import config from '@/config'
|
||||
import { Fields } from '../../../types'
|
||||
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
|
||||
|
||||
/**
|
||||
* ValidationContainer component - the main wrapper for the validation step
|
||||
@@ -950,7 +951,7 @@ const ValidationContainer = <T extends string>({
|
||||
/>
|
||||
|
||||
{/* Product Search Dialog */}
|
||||
<ProductSearchDialog
|
||||
<SearchProductTemplateDialog
|
||||
isOpen={isProductSearchDialogOpen}
|
||||
onClose={() => setIsProductSearchDialogOpen(false)}
|
||||
onTemplateCreated={loadTemplates}
|
||||
|
||||
Reference in New Issue
Block a user