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 axios from 'axios';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -106,6 +106,174 @@ interface TemplateFormData {
|
|||||||
type SortDirection = 'asc' | 'desc' | null;
|
type SortDirection = 'asc' | 'desc' | null;
|
||||||
type SortField = 'title' | 'brand' | 'line' | 'price' | 'total_sold' | 'first_received' | 'date_last_sold' | null;
|
type SortField = 'title' | 'brand' | 'line' | 'price' | 'total_sold' | 'first_received' | 'date_last_sold' | null;
|
||||||
|
|
||||||
|
// Date filter options
|
||||||
|
const DATE_FILTER_OPTIONS = [
|
||||||
|
{ label: "Any time", value: "none" },
|
||||||
|
{ label: "Last week", value: "1week" },
|
||||||
|
{ label: "Last month", value: "1month" },
|
||||||
|
{ label: "Last 2 months", value: "2months" },
|
||||||
|
{ label: "Last 3 months", value: "3months" },
|
||||||
|
{ label: "Last 6 months", value: "6months" },
|
||||||
|
{ label: "Last year", value: "1year" }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create a memoized search component to prevent unnecessary re-renders
|
||||||
|
const SearchInput = React.memo(({
|
||||||
|
searchTerm,
|
||||||
|
setSearchTerm,
|
||||||
|
handleSearch,
|
||||||
|
isLoading,
|
||||||
|
onClear
|
||||||
|
}: {
|
||||||
|
searchTerm: string;
|
||||||
|
setSearchTerm: (term: string) => void;
|
||||||
|
handleSearch: () => void;
|
||||||
|
isLoading: boolean;
|
||||||
|
onClear: () => void;
|
||||||
|
}) => (
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); handleSearch(); }} className="flex items-center space-x-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Search..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
className="pr-8"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-0 top-0 h-full w-8 p-0"
|
||||||
|
onClick={onClear}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
));
|
||||||
|
|
||||||
|
// Create a memoized filter component
|
||||||
|
const FilterSelects = React.memo(({
|
||||||
|
selectedCompany,
|
||||||
|
setSelectedCompany,
|
||||||
|
selectedDateFilter,
|
||||||
|
setSelectedDateFilter,
|
||||||
|
companies,
|
||||||
|
onFilterChange
|
||||||
|
}: {
|
||||||
|
selectedCompany: string;
|
||||||
|
setSelectedCompany: (company: string) => void;
|
||||||
|
selectedDateFilter: string;
|
||||||
|
setSelectedDateFilter: (filter: string) => void;
|
||||||
|
companies: { label: string; value: string; }[];
|
||||||
|
onFilterChange: () => void;
|
||||||
|
}) => (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="company-filter" className="text-sm">Filter by Company</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedCompany}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedCompany(value);
|
||||||
|
onFilterChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="company-filter">
|
||||||
|
<SelectValue placeholder="Any Company" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">Any Company</SelectItem>
|
||||||
|
{companies.map((company) => (
|
||||||
|
<SelectItem key={company.value} value={company.value}>
|
||||||
|
{company.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="date-filter" className="text-sm">Filter by Date Received</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedDateFilter}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
setSelectedDateFilter(value);
|
||||||
|
onFilterChange();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="date-filter">
|
||||||
|
<SelectValue placeholder="Any time" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DATE_FILTER_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
// Create a memoized results table component
|
||||||
|
const ResultsTable = React.memo(({
|
||||||
|
results,
|
||||||
|
selectedCompany,
|
||||||
|
onSelect
|
||||||
|
}: {
|
||||||
|
results: any[];
|
||||||
|
selectedCompany: string;
|
||||||
|
onSelect: (product: any) => void;
|
||||||
|
}) => (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Name</TableHead>
|
||||||
|
{selectedCompany === 'all' && <TableHead>Company</TableHead>}
|
||||||
|
<TableHead>Line</TableHead>
|
||||||
|
<TableHead>Price</TableHead>
|
||||||
|
<TableHead>Total Sold</TableHead>
|
||||||
|
<TableHead>Date In</TableHead>
|
||||||
|
<TableHead>Last Sold</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{results.map((product) => (
|
||||||
|
<TableRow
|
||||||
|
key={product.pid}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => onSelect(product)}
|
||||||
|
>
|
||||||
|
<TableCell>{product.title}</TableCell>
|
||||||
|
{selectedCompany === 'all' && <TableCell>{product.brand || '-'}</TableCell>}
|
||||||
|
<TableCell>{product.line || '-'}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{product.price != null ? `$${Number(product.price).toFixed(2)}` : '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{product.total_sold || 0}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{product.first_received
|
||||||
|
? new Date(product.first_received).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{product.date_last_sold
|
||||||
|
? new Date(product.date_last_sold).toLocaleDateString()
|
||||||
|
: '-'}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
));
|
||||||
|
|
||||||
export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) {
|
export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: ProductSearchDialogProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [searchResults, setSearchResults] = useState<Product[]>([]);
|
const [searchResults, setSearchResults] = useState<Product[]>([]);
|
||||||
@@ -122,25 +290,84 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
const productsPerPage = 500;
|
const productsPerPage = 500;
|
||||||
|
|
||||||
// Filter states
|
// Filter states
|
||||||
const [companyFilter, setCompanyFilter] = useState<string>('all');
|
const [selectedCompany, setSelectedCompany] = useState<string>('all');
|
||||||
const [dateInFilter, setDateInFilter] = useState<string>('none');
|
const [selectedDateFilter, setSelectedDateFilter] = useState<string>('none');
|
||||||
|
|
||||||
// Sorting states
|
// Sorting states
|
||||||
const [sortField, setSortField] = useState<SortField>(null);
|
const [sortField, setSortField] = useState<SortField>(null);
|
||||||
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
const [sortDirection, setSortDirection] = useState<SortDirection>(null);
|
||||||
|
|
||||||
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
|
|
||||||
|
// Add this new function to handle all search state changes
|
||||||
|
const handleSearchStateChange = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
setHasSearched(true);
|
||||||
|
|
||||||
|
// If no active filters or search term, reset everything
|
||||||
|
if (!searchTerm.trim() && selectedCompany === 'all' && selectedDateFilter === 'none') {
|
||||||
|
setSearchResults([]);
|
||||||
|
setHasSearched(false);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Always include search term if present
|
||||||
|
if (searchTerm.trim()) {
|
||||||
|
params.q = searchTerm.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add filters
|
||||||
|
if (selectedCompany !== 'all') {
|
||||||
|
params.company = selectedCompany;
|
||||||
|
// Only use wildcard if there's no search term
|
||||||
|
if (!searchTerm.trim()) {
|
||||||
|
params.q = '*';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDateFilter !== 'none') {
|
||||||
|
params.dateRange = selectedDateFilter;
|
||||||
|
// Only use wildcard if there's no search term and no company filter
|
||||||
|
if (!searchTerm.trim() && selectedCompany === 'all') {
|
||||||
|
params.q = '*';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await axios.get('/api/import/search-products', { params });
|
||||||
|
setSearchResults(response.data.map((product: Product) => ({
|
||||||
|
...product,
|
||||||
|
pid: typeof product.pid === 'string' ? parseInt(product.pid, 10) : product.pid,
|
||||||
|
price: typeof product.price === 'string' ? parseFloat(product.price) : product.price,
|
||||||
|
// ... other parsing remains the same
|
||||||
|
})));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error searching products:', error);
|
||||||
|
toast.error('Failed to search products', {
|
||||||
|
description: 'Could not retrieve search results from the server'
|
||||||
|
});
|
||||||
|
setSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Reset all search state when dialog is closed
|
// Reset all search state when dialog is closed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) {
|
if (!isOpen) {
|
||||||
// Reset search state
|
// Reset search state
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setCompanyFilter('all');
|
setSelectedCompany('all');
|
||||||
setDateInFilter('none');
|
setSelectedDateFilter('none');
|
||||||
setSortField(null);
|
setSortField(null);
|
||||||
setSortDirection(null);
|
setSortDirection(null);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setStep('search');
|
setStep('search');
|
||||||
|
setHasSearched(false);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
@@ -149,21 +376,18 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
fetchFieldOptions();
|
fetchFieldOptions();
|
||||||
// Perform initial search if any filters are already applied
|
// Perform initial search if any filters are already applied
|
||||||
if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) {
|
if (selectedCompany !== 'all' || selectedDateFilter !== 'none') {
|
||||||
performSearch();
|
handleSearchStateChange();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Reset pagination when filters change and trigger search
|
// Update the useEffect for filter changes to use the new search function
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
if (selectedCompany !== 'all' || selectedDateFilter !== 'none') {
|
||||||
|
handleSearchStateChange();
|
||||||
// Trigger search when company filter changes
|
|
||||||
if (companyFilter !== 'all' || (dateInFilter && dateInFilter !== 'none')) {
|
|
||||||
performSearch();
|
|
||||||
}
|
}
|
||||||
}, [companyFilter, dateInFilter]);
|
}, [selectedCompany, selectedDateFilter]);
|
||||||
|
|
||||||
// Fetch product lines when company changes
|
// Fetch product lines when company changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -194,78 +418,20 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Common search function for all search scenarios
|
// Update the search input handlers
|
||||||
const performSearch = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Prepare query parameters
|
|
||||||
const params: Record<string, any> = {};
|
|
||||||
|
|
||||||
// Always include a search parameter - necessary for the API
|
|
||||||
// If there's a search term, use it, otherwise use a wildcard/empty search
|
|
||||||
params.q = searchTerm.trim() || "*";
|
|
||||||
|
|
||||||
// Add company filter if selected
|
|
||||||
if (companyFilter && companyFilter !== 'all') {
|
|
||||||
params.company = companyFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add date range filter if selected
|
|
||||||
if (dateInFilter && dateInFilter !== 'none') {
|
|
||||||
params.dateRange = dateInFilter;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await axios.get('/api/import/search-products', { params });
|
|
||||||
|
|
||||||
const parsedResults = response.data.map((product: Product) => {
|
|
||||||
const parsedProduct = {
|
|
||||||
...product,
|
|
||||||
// Ensure pid is a number
|
|
||||||
pid: typeof product.pid === 'string' ? parseInt(product.pid, 10) : product.pid,
|
|
||||||
// Convert string numeric values to actual numbers
|
|
||||||
price: typeof product.price === 'string' ? parseFloat(product.price) : product.price,
|
|
||||||
regular_price: typeof product.regular_price === 'string' ? parseFloat(product.regular_price) : product.regular_price,
|
|
||||||
cost_price: product.cost_price !== null && typeof product.cost_price === 'string' ? parseFloat(product.cost_price) : product.cost_price,
|
|
||||||
brand_id: product.brand_id !== null ? String(product.brand_id) : '',
|
|
||||||
line_id: product.line_id !== null ? String(product.line_id) : '0',
|
|
||||||
subline_id: product.subline_id !== null ? String(product.subline_id) : '0',
|
|
||||||
artist_id: product.artist_id !== null ? String(product.artist_id) : '0',
|
|
||||||
moq: typeof product.moq === 'string' ? parseInt(product.moq, 10) : product.moq,
|
|
||||||
weight: typeof product.weight === 'string' ? parseFloat(product.weight) : product.weight,
|
|
||||||
length: typeof product.length === 'string' ? parseFloat(product.length) : product.length,
|
|
||||||
width: typeof product.width === 'string' ? parseFloat(product.width) : product.width,
|
|
||||||
height: typeof product.height === 'string' ? parseFloat(product.height) : product.height,
|
|
||||||
total_sold: typeof product.total_sold === 'string' ? parseInt(product.total_sold, 10) : product.total_sold
|
|
||||||
};
|
|
||||||
|
|
||||||
return parsedProduct;
|
|
||||||
});
|
|
||||||
|
|
||||||
setSearchResults(parsedResults);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error searching products:', error);
|
|
||||||
toast.error('Failed to search products', {
|
|
||||||
description: 'Could not retrieve search results from the server'
|
|
||||||
});
|
|
||||||
setSearchResults([]);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
performSearch();
|
handleSearchStateChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCompanyFilterChange = (value: string) => {
|
const clearSearch = () => {
|
||||||
setCompanyFilter(value);
|
setSearchTerm('');
|
||||||
// The useEffect will handle the search
|
handleSearchStateChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDateFilterChange = (value: string) => {
|
const clearFilters = () => {
|
||||||
setDateInFilter(value);
|
setSelectedCompany('all');
|
||||||
// The useEffect will handle the search
|
setSelectedDateFilter('none');
|
||||||
|
handleSearchStateChange();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProductSelect = (product: Product) => {
|
const handleProductSelect = (product: Product) => {
|
||||||
@@ -337,14 +503,12 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
try {
|
try {
|
||||||
const response = await axios.get(`/api/import/product-categories/${productId}`);
|
const response = await axios.get(`/api/import/product-categories/${productId}`);
|
||||||
|
|
||||||
|
|
||||||
if (response.data && Array.isArray(response.data)) {
|
if (response.data && Array.isArray(response.data)) {
|
||||||
// Filter out categories with type 20 (themes) and type 21 (subthemes)
|
// Filter out categories with type 20 (themes) and type 21 (subthemes)
|
||||||
const filteredCategories = response.data.filter((category: any) =>
|
const filteredCategories = response.data.filter((category: any) =>
|
||||||
category.type !== 20 && category.type !== 21
|
category.type !== 20 && category.type !== 21
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
// Extract category IDs and update form data
|
// Extract category IDs and update form data
|
||||||
const categoryIds = filteredCategories.map((category: any) => category.value);
|
const categoryIds = filteredCategories.map((category: any) => category.value);
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -631,7 +795,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
|
|
||||||
return searchResults.filter(product => {
|
return searchResults.filter(product => {
|
||||||
// Apply company filter if set
|
// Apply company filter if set
|
||||||
if (companyFilter && companyFilter !== "all" && product.brand_id !== companyFilter) {
|
if (selectedCompany && selectedCompany !== "all" && product.brand_id !== selectedCompany) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -651,19 +815,6 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered);
|
const currentProductsFiltered = sortedResults.slice(indexOfFirstProductFiltered, indexOfLastProductFiltered);
|
||||||
const totalPagesFiltered = Math.ceil(sortedResults.length / productsPerPage);
|
const totalPagesFiltered = Math.ceil(sortedResults.length / productsPerPage);
|
||||||
|
|
||||||
const clearFilters = () => {
|
|
||||||
setCompanyFilter('all');
|
|
||||||
setDateInFilter('none');
|
|
||||||
|
|
||||||
// If search term is empty, clear results completely
|
|
||||||
if (!searchTerm.trim()) {
|
|
||||||
setSearchResults([]);
|
|
||||||
} else {
|
|
||||||
// Otherwise, perform a search with just the search term
|
|
||||||
performSearch();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get sort icon
|
// Get sort icon
|
||||||
const getSortIcon = (field: SortField) => {
|
const getSortIcon = (field: SortField) => {
|
||||||
if (sortField !== field) {
|
if (sortField !== field) {
|
||||||
@@ -700,125 +851,57 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
|
|
||||||
<div className="flex-1 overflow-hidden py-4 px-1">
|
<div className="flex-1 overflow-hidden py-4 px-1">
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<div className="flex items-center space-x-2">
|
<SearchInput
|
||||||
<div className="relative flex-1">
|
searchTerm={searchTerm}
|
||||||
<Input
|
setSearchTerm={setSearchTerm}
|
||||||
placeholder="Search..."
|
handleSearch={handleSearch}
|
||||||
value={searchTerm}
|
isLoading={isLoading}
|
||||||
onChange={(e) => {
|
onClear={clearSearch}
|
||||||
// Just update the search term without triggering search
|
/>
|
||||||
setSearchTerm(e.target.value);
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
className="pr-8"
|
|
||||||
/>
|
|
||||||
{searchTerm && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute right-0 top-0 h-full w-8 p-0"
|
|
||||||
onClick={() => {
|
|
||||||
setSearchTerm('');
|
|
||||||
// If there are active filters, perform a search with just the filters
|
|
||||||
if (companyFilter !== 'all' || dateInFilter !== 'none') {
|
|
||||||
setTimeout(() => performSearch(), 0);
|
|
||||||
} else {
|
|
||||||
// Otherwise, clear the results completely
|
|
||||||
setSearchResults([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button onClick={handleSearch} disabled={isLoading}>
|
|
||||||
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Search className="h-4 w-4" />}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{fieldOptions ? (
|
<FilterSelects
|
||||||
<div className="grid grid-cols-2 gap-4">
|
selectedCompany={selectedCompany}
|
||||||
<div>
|
setSelectedCompany={setSelectedCompany}
|
||||||
<Label htmlFor="company-filter" className="text-sm">Filter by Company</Label>
|
selectedDateFilter={selectedDateFilter}
|
||||||
<Select
|
setSelectedDateFilter={setSelectedDateFilter}
|
||||||
value={companyFilter}
|
companies={fieldOptions?.companies || []}
|
||||||
onValueChange={handleCompanyFilterChange}
|
onFilterChange={handleSearchStateChange}
|
||||||
>
|
/>
|
||||||
<SelectTrigger id="company-filter" className="w-full">
|
|
||||||
<SelectValue placeholder="All Companies" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Companies</SelectItem>
|
|
||||||
{fieldOptions.companies?.map((company) => (
|
|
||||||
<SelectItem key={company.value} value={company.value}>
|
|
||||||
{company.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
{fieldOptions && ((selectedCompany && selectedCompany !== 'all') || selectedDateFilter !== 'none') && (
|
||||||
<Label htmlFor="date-filter" className="text-sm">Filter by Date Received</Label>
|
|
||||||
<Select
|
|
||||||
value={dateInFilter}
|
|
||||||
onValueChange={handleDateFilterChange}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="date-filter" className="w-full">
|
|
||||||
<SelectValue placeholder="Any time" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="none">Any time</SelectItem>
|
|
||||||
<SelectItem value="1week">Last week</SelectItem>
|
|
||||||
<SelectItem value="1month">Last month</SelectItem>
|
|
||||||
<SelectItem value="2months">Last 2 months</SelectItem>
|
|
||||||
<SelectItem value="3months">Last 3 months</SelectItem>
|
|
||||||
<SelectItem value="6months">Last 6 months</SelectItem>
|
|
||||||
<SelectItem value="1year">Last year</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-2 text-sm text-muted-foreground">Loading filter options...</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{fieldOptions && ((companyFilter && companyFilter !== 'all') || dateInFilter !== 'none') && (
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<div className="text-sm text-muted-foreground">Active filters:</div>
|
<div className="text-sm text-muted-foreground">Active filters:</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{companyFilter && companyFilter !== "all" && (
|
{selectedCompany && selectedCompany !== "all" && (
|
||||||
<Badge variant="secondary" className="flex items-center gap-1">
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
Company: {fieldOptions?.companies?.find(c => c.value === companyFilter)?.label || 'Unknown'}
|
Company: {fieldOptions?.companies?.find(c => c.value === selectedCompany)?.label || 'Unknown'}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-4 w-4 p-0 ml-1"
|
className="h-4 w-4 p-0 ml-1"
|
||||||
onClick={() => handleCompanyFilterChange('all')}
|
onClick={() => {
|
||||||
|
setSelectedCompany('all');
|
||||||
|
handleSearchStateChange();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
{dateInFilter && dateInFilter !== 'none' && (
|
{selectedDateFilter && selectedDateFilter !== 'none' && (
|
||||||
<Badge variant="secondary" className="flex items-center gap-1">
|
<Badge variant="secondary" className="flex items-center gap-1">
|
||||||
Date: {(() => {
|
Date: {(() => {
|
||||||
switch(dateInFilter) {
|
const selectedOption = DATE_FILTER_OPTIONS.find(o => o.value === selectedDateFilter);
|
||||||
case '1week': return 'Last week';
|
return selectedOption ? selectedOption.label : 'Custom range';
|
||||||
case '1month': return 'Last month';
|
|
||||||
case '2months': return 'Last 2 months';
|
|
||||||
case '3months': return 'Last 3 months';
|
|
||||||
case '6months': return 'Last 6 months';
|
|
||||||
case '1year': return 'Last year';
|
|
||||||
default: return 'Custom range';
|
|
||||||
}
|
|
||||||
})()}
|
})()}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-4 w-4 p-0 ml-1"
|
className="h-4 w-4 p-0 ml-1"
|
||||||
onClick={() => handleDateFilterChange("none")}
|
onClick={() => {
|
||||||
|
setSelectedDateFilter('none');
|
||||||
|
handleSearchStateChange();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -840,14 +923,17 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
<ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto max-h-[60vh]">
|
<ScrollArea className="flex-1 -mr-6 pr-6 overflow-y-auto max-h-[60vh]">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
<div className="text-center py-4 text-muted-foreground flex items-center justify-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span>Searching...</span>
|
||||||
|
</div>
|
||||||
|
) : !hasSearched ? (
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
Searching...
|
Use the search field or filters to find products
|
||||||
</div>
|
</div>
|
||||||
) : searchResults.length === 0 ? (
|
) : searchResults.length === 0 ? (
|
||||||
<div className="text-center py-4 text-muted-foreground">
|
<div className="text-center py-4 text-muted-foreground">
|
||||||
{companyFilter !== 'all' || dateInFilter !== 'none' || searchTerm.trim()
|
No products found matching your criteria
|
||||||
? 'No products found matching your criteria'
|
|
||||||
: 'Use the search field or filters to find products'}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -859,7 +945,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
<TableHeader className="sticky top-0 bg-background z-10 border-b">
|
<TableHeader className="sticky top-0 bg-background z-10 border-b">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<SortableTableHead field="title">Name</SortableTableHead>
|
<SortableTableHead field="title">Name</SortableTableHead>
|
||||||
{(!companyFilter || companyFilter === "all") && (
|
{selectedCompany === 'all' && (
|
||||||
<SortableTableHead field="brand">Company</SortableTableHead>
|
<SortableTableHead field="brand">Company</SortableTableHead>
|
||||||
)}
|
)}
|
||||||
<SortableTableHead field="line">Line</SortableTableHead>
|
<SortableTableHead field="line">Line</SortableTableHead>
|
||||||
@@ -877,7 +963,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
onClick={() => handleProductSelect(product)}
|
onClick={() => handleProductSelect(product)}
|
||||||
>
|
>
|
||||||
<TableCell className="font-medium">{product.title}</TableCell>
|
<TableCell className="font-medium">{product.title}</TableCell>
|
||||||
{(!companyFilter || companyFilter === "all") && (
|
{selectedCompany === 'all' && (
|
||||||
<TableCell>{product.brand || '-'}</TableCell>
|
<TableCell>{product.brand || '-'}</TableCell>
|
||||||
)}
|
)}
|
||||||
<TableCell>{product.line || '-'}</TableCell>
|
<TableCell>{product.line || '-'}</TableCell>
|
||||||
@@ -1516,12 +1602,7 @@ export function ProductSearchDialog({ isOpen, onClose, onTemplateCreated }: Prod
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={isOpen} onOpenChange={(open) => {
|
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||||
if (!open) {
|
|
||||||
// When dialog is closed externally (like clicking outside)
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
}}>
|
|
||||||
<DialogContent className={`max-h-[95vh] flex flex-col p-6 ${step === 'search' ? 'max-w-5xl' : 'max-w-2xl'}`}>
|
<DialogContent className={`max-h-[95vh] flex flex-col p-6 ${step === 'search' ? 'max-w-5xl' : 'max-w-2xl'}`}>
|
||||||
{step === 'search' ? renderSearchStep() : renderFormStep()}
|
{step === 'search' ? renderSearchStep() : renderFormStep()}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import {
|
|||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { SearchProductTemplateDialog } from "@/components/templates/SearchProductTemplateDialog";
|
||||||
|
import { TemplateForm } from "@/components/templates/TemplateForm";
|
||||||
|
|
||||||
interface FieldOption {
|
interface FieldOption {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -106,14 +108,10 @@ interface TemplateFormData extends Omit<Template, 'id' | 'created_at' | 'updated
|
|||||||
|
|
||||||
export function TemplateManagement() {
|
export function TemplateManagement() {
|
||||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||||
const [isEditOpen, setIsEditOpen] = useState(false);
|
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(null);
|
const [templateToDelete, setTemplateToDelete] = useState<Template | null>(null);
|
||||||
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
|
const [editingTemplate, setEditingTemplate] = useState<Template | null>(null);
|
||||||
const [formData, setFormData] = useState<TemplateFormData>({
|
|
||||||
company: "",
|
|
||||||
product_type: "",
|
|
||||||
});
|
|
||||||
const [sorting, setSorting] = useState<SortingState>([]);
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
||||||
@@ -141,56 +139,6 @@ export function TemplateManagement() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
|
||||||
mutationFn: async (data: TemplateFormData) => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/templates`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to create template");
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
|
||||||
setIsCreateOpen(false);
|
|
||||||
setFormData({ company: "", product_type: "" });
|
|
||||||
toast.success("Template created successfully");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to create template");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
|
||||||
mutationFn: async ({ id, data }: { id: number; data: TemplateFormData }) => {
|
|
||||||
const response = await fetch(`${config.apiUrl}/templates/${id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(data),
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error("Failed to update template");
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
|
||||||
setIsEditOpen(false);
|
|
||||||
setEditingTemplate(null);
|
|
||||||
toast.success("Template updated successfully");
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error(error instanceof Error ? error.message : "Failed to update template");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: async (id: number) => {
|
mutationFn: async (id: number) => {
|
||||||
const response = await fetch(`${config.apiUrl}/templates/${id}`, {
|
const response = await fetch(`${config.apiUrl}/templates/${id}`, {
|
||||||
@@ -209,100 +157,19 @@ export function TemplateManagement() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (editingTemplate) {
|
|
||||||
updateMutation.mutate({ id: editingTemplate.id, data: formData });
|
|
||||||
} else {
|
|
||||||
createMutation.mutate(formData);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (template: Template) => {
|
const handleEdit = (template: Template) => {
|
||||||
setEditingTemplate(template);
|
setEditingTemplate(template);
|
||||||
setFormData({
|
|
||||||
company: template.company,
|
|
||||||
product_type: template.product_type,
|
|
||||||
supplier: template.supplier,
|
|
||||||
msrp: template.msrp,
|
|
||||||
cost_each: template.cost_each,
|
|
||||||
qty_per_unit: template.qty_per_unit,
|
|
||||||
case_qty: template.case_qty,
|
|
||||||
hts_code: template.hts_code,
|
|
||||||
description: template.description,
|
|
||||||
weight: template.weight,
|
|
||||||
length: template.length,
|
|
||||||
width: template.width,
|
|
||||||
height: template.height,
|
|
||||||
tax_cat: template.tax_cat,
|
|
||||||
size_cat: template.size_cat,
|
|
||||||
categories: template.categories,
|
|
||||||
ship_restrictions: template.ship_restrictions,
|
|
||||||
});
|
|
||||||
setIsEditOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleCopy = (template: Template) => {
|
||||||
const { name, value } = e.target;
|
// Remove id and timestamps for copy operation
|
||||||
setFormData((prev) => ({
|
const { id, created_at, updated_at, ...templateData } = template;
|
||||||
...prev,
|
// Set as new template with empty product type
|
||||||
[name]: value,
|
setEditingTemplate({
|
||||||
}));
|
...templateData,
|
||||||
};
|
product_type: '',
|
||||||
|
id: 0 // Add a temporary ID to indicate it's a copy
|
||||||
const handleSelectChange = (name: string, value: string) => {
|
} as Template);
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMultiSelectChange = (name: string, value: string) => {
|
|
||||||
setFormData((prev) => {
|
|
||||||
const currentValues = prev[name as keyof typeof prev] as string[] || [];
|
|
||||||
const valueSet = new Set(currentValues);
|
|
||||||
|
|
||||||
if (valueSet.has(value)) {
|
|
||||||
valueSet.delete(value);
|
|
||||||
} else {
|
|
||||||
valueSet.add(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
[name]: Array.from(valueSet),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleNumberInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value === "" ? undefined : Number(value),
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
|
||||||
const commandList = e.currentTarget;
|
|
||||||
commandList.scrollTop += e.deltaY;
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSelect = (name: string, value: string, closeOnSelect = true) => {
|
|
||||||
handleSelectChange(name, value);
|
|
||||||
if (closeOnSelect) {
|
|
||||||
const button = document.activeElement as HTMLElement;
|
|
||||||
button?.blur();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTextAreaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (template: Template) => {
|
const handleDeleteClick = (template: Template) => {
|
||||||
@@ -318,27 +185,8 @@ export function TemplateManagement() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopy = (template: Template) => {
|
const handleTemplateSuccess = () => {
|
||||||
setFormData({
|
queryClient.invalidateQueries({ queryKey: ["templates"] });
|
||||||
company: template.company,
|
|
||||||
product_type: template.product_type,
|
|
||||||
supplier: template.supplier,
|
|
||||||
msrp: template.msrp,
|
|
||||||
cost_each: template.cost_each,
|
|
||||||
qty_per_unit: template.qty_per_unit,
|
|
||||||
case_qty: template.case_qty,
|
|
||||||
hts_code: template.hts_code,
|
|
||||||
description: template.description,
|
|
||||||
weight: template.weight,
|
|
||||||
length: template.length,
|
|
||||||
width: template.width,
|
|
||||||
height: template.height,
|
|
||||||
tax_cat: template.tax_cat,
|
|
||||||
size_cat: template.size_cat,
|
|
||||||
categories: template.categories,
|
|
||||||
ship_restrictions: template.ship_restrictions,
|
|
||||||
});
|
|
||||||
setIsCreateOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<Template>[]>(() => [
|
const columns = useMemo<ColumnDef<Template>[]>(() => [
|
||||||
@@ -437,558 +285,18 @@ export function TemplateManagement() {
|
|||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderFormFields = () => {
|
|
||||||
if (!fieldOptions) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center p-8">
|
|
||||||
<Loader2 className="h-8 w-8 animate-spin" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to sort options with selected value at top
|
|
||||||
const getSortedOptions = (options: FieldOption[], selectedValue?: string | string[]) => {
|
|
||||||
return [...options].sort((a, b) => {
|
|
||||||
if (Array.isArray(selectedValue)) {
|
|
||||||
const aSelected = selectedValue.includes(a.value);
|
|
||||||
const bSelected = selectedValue.includes(b.value);
|
|
||||||
if (aSelected && !bSelected) return -1;
|
|
||||||
if (!aSelected && bSelected) return 1;
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (a.value === selectedValue) return -1;
|
|
||||||
if (b.value === selectedValue) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Helper function to get selected category labels
|
|
||||||
const getSelectedCategoryLabels = () => {
|
|
||||||
if (!formData.categories?.length) return "";
|
|
||||||
const labels = formData.categories
|
|
||||||
.map(value => fieldOptions.categories.find(cat => cat.value === value)?.label)
|
|
||||||
.filter(Boolean);
|
|
||||||
return labels.join(", ");
|
|
||||||
};
|
|
||||||
|
|
||||||
// Sort categories with selected ones first and respect levels
|
|
||||||
const getSortedCategories = () => {
|
|
||||||
const selected = new Set(formData.categories || []);
|
|
||||||
return [...fieldOptions.categories].sort((a, b) => {
|
|
||||||
const aSelected = selected.has(a.value);
|
|
||||||
const bSelected = selected.has(b.value);
|
|
||||||
if (aSelected && !bSelected) return -1;
|
|
||||||
if (!aSelected && bSelected) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid gap-4 py-4 px-1">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="company">Company*</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
!formData.company && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formData.company
|
|
||||||
? fieldOptions.companies.find(
|
|
||||||
(company) => company.value === formData.company
|
|
||||||
)?.label
|
|
||||||
: "Select company..."}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search companies..." />
|
|
||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
|
||||||
<CommandEmpty>No companies found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{getSortedOptions(fieldOptions.companies, formData.company).map((company) => (
|
|
||||||
<CommandItem
|
|
||||||
key={company.value}
|
|
||||||
value={company.label}
|
|
||||||
onSelect={() => handleSelect("company", company.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSelect("company", company.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
formData.company === company.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{company.label}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="product_type">Product Type*</Label>
|
|
||||||
<Input
|
|
||||||
id="product_type"
|
|
||||||
name="product_type"
|
|
||||||
value={formData.product_type}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="supplier">Supplier</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
!formData.supplier && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formData.supplier
|
|
||||||
? fieldOptions.suppliers.find(
|
|
||||||
(supplier) => supplier.value === formData.supplier
|
|
||||||
)?.label
|
|
||||||
: "Select supplier..."}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search suppliers..." />
|
|
||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
|
||||||
<CommandEmpty>No suppliers found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{getSortedOptions(fieldOptions.suppliers, formData.supplier).map((supplier) => (
|
|
||||||
<CommandItem
|
|
||||||
key={supplier.value}
|
|
||||||
value={supplier.label}
|
|
||||||
onSelect={() => handleSelect("supplier", supplier.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSelect("supplier", supplier.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
formData.supplier === supplier.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{supplier.label}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="msrp">MSRP</Label>
|
|
||||||
<Input
|
|
||||||
id="msrp"
|
|
||||||
name="msrp"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.msrp || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="cost_each">Cost Each</Label>
|
|
||||||
<Input
|
|
||||||
id="cost_each"
|
|
||||||
name="cost_each"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.cost_each || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="qty_per_unit">Quantity per Unit</Label>
|
|
||||||
<Input
|
|
||||||
id="qty_per_unit"
|
|
||||||
name="qty_per_unit"
|
|
||||||
type="number"
|
|
||||||
value={formData.qty_per_unit || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="case_qty">Case Quantity</Label>
|
|
||||||
<Input
|
|
||||||
id="case_qty"
|
|
||||||
name="case_qty"
|
|
||||||
type="number"
|
|
||||||
value={formData.case_qty || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="ship_restrictions">Shipping Restrictions</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
!formData.ship_restrictions?.[0] && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formData.ship_restrictions?.[0]
|
|
||||||
? fieldOptions.shippingRestrictions.find(
|
|
||||||
(r) => r.value === formData.ship_restrictions?.[0]
|
|
||||||
)?.label
|
|
||||||
: "Select shipping restriction..."}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search restrictions..." />
|
|
||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
|
||||||
<CommandEmpty>No restrictions found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{getSortedOptions(fieldOptions.shippingRestrictions, formData.ship_restrictions?.[0]).map((restriction) => (
|
|
||||||
<CommandItem
|
|
||||||
key={restriction.value}
|
|
||||||
value={restriction.label}
|
|
||||||
onSelect={(value) => {
|
|
||||||
const foundRestriction = fieldOptions.shippingRestrictions.find(
|
|
||||||
r => r.label === value
|
|
||||||
);
|
|
||||||
if (foundRestriction) {
|
|
||||||
handleSelect("ship_restrictions", foundRestriction.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
const foundRestriction = fieldOptions.shippingRestrictions.find(
|
|
||||||
r => r.label === restriction.label
|
|
||||||
);
|
|
||||||
if (foundRestriction) {
|
|
||||||
handleSelect("ship_restrictions", foundRestriction.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
formData.ship_restrictions?.[0] === restriction.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{restriction.label}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="hts_code">HTS Code</Label>
|
|
||||||
<Input
|
|
||||||
id="hts_code"
|
|
||||||
name="hts_code"
|
|
||||||
value={formData.hts_code || ""}
|
|
||||||
onChange={handleInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
|
||||||
<div className="grid grid-cols-4 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="weight">Weight (oz)</Label>
|
|
||||||
<Input
|
|
||||||
id="weight"
|
|
||||||
name="weight"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.weight || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="length">Length (in)</Label>
|
|
||||||
<Input
|
|
||||||
id="length"
|
|
||||||
name="length"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.length || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="width">Width (in)</Label>
|
|
||||||
<Input
|
|
||||||
id="width"
|
|
||||||
name="width"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.width || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="height">Height (in)</Label>
|
|
||||||
<Input
|
|
||||||
id="height"
|
|
||||||
name="height"
|
|
||||||
type="number"
|
|
||||||
step="0.01"
|
|
||||||
value={formData.height || ""}
|
|
||||||
onChange={handleNumberInputChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="tax_cat">Tax Category</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
!formData.tax_cat && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formData.tax_cat !== undefined
|
|
||||||
? fieldOptions.taxCategories.find(
|
|
||||||
(cat) => cat.value === formData.tax_cat
|
|
||||||
)?.label
|
|
||||||
: "Select tax category..."}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search tax categories..." />
|
|
||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
|
||||||
<CommandEmpty>No tax categories found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{getSortedOptions(fieldOptions.taxCategories, formData.tax_cat).map((category) => (
|
|
||||||
<CommandItem
|
|
||||||
key={category.value}
|
|
||||||
value={category.value}
|
|
||||||
onSelect={() => handleSelect("tax_cat", category.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSelect("tax_cat", category.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
formData.tax_cat === category.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{category.label}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="size_cat">Size Category</Label>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className={cn(
|
|
||||||
"w-full justify-between",
|
|
||||||
!formData.size_cat && "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formData.size_cat
|
|
||||||
? fieldOptions.sizes.find(
|
|
||||||
(size) => size.value === formData.size_cat
|
|
||||||
)?.label
|
|
||||||
: "Select size category..."}
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0">
|
|
||||||
<Command>
|
|
||||||
<CommandInput placeholder="Search sizes..." />
|
|
||||||
<CommandList className="max-h-[200px] overflow-y-auto" onWheel={handleWheel}>
|
|
||||||
<CommandEmpty>No sizes found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{getSortedOptions(fieldOptions.sizes, formData.size_cat).map((size) => (
|
|
||||||
<CommandItem
|
|
||||||
key={size.value}
|
|
||||||
value={size.value}
|
|
||||||
onSelect={() => handleSelect("size_cat", size.value)}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
handleSelect("size_cat", size.value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4",
|
|
||||||
formData.size_cat === size.value
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{size.label}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="categories">Categories</Label>
|
|
||||||
<Popover modal={false}>
|
|
||||||
<PopoverTrigger asChild className="max-w-[calc(800px-3.5rem)]">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
role="combobox"
|
|
||||||
className="w-full justify-between"
|
|
||||||
>
|
|
||||||
<span className={cn(
|
|
||||||
"truncate block flex-1 text-left",
|
|
||||||
!formData.categories?.length && "text-muted-foreground"
|
|
||||||
)}>
|
|
||||||
{formData.categories?.length
|
|
||||||
? getSelectedCategoryLabels()
|
|
||||||
: "Select categories..."}
|
|
||||||
</span>
|
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
|
|
||||||
<Command className="max-h-[200px]">
|
|
||||||
<CommandInput placeholder="Search categories..." />
|
|
||||||
<CommandList onWheel={handleWheel}>
|
|
||||||
<CommandEmpty>No categories found.</CommandEmpty>
|
|
||||||
<CommandGroup>
|
|
||||||
{getSortedCategories().map((category) => (
|
|
||||||
<CommandItem
|
|
||||||
key={category.value}
|
|
||||||
value={category.label}
|
|
||||||
onSelect={() => handleMultiSelectChange("categories", category.value)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center w-full">
|
|
||||||
<Check
|
|
||||||
className={cn(
|
|
||||||
"mr-2 h-4 w-4 flex-shrink-0",
|
|
||||||
(formData.categories || []).includes(category.value)
|
|
||||||
? "opacity-100"
|
|
||||||
: "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span className={cn(
|
|
||||||
"truncate",
|
|
||||||
category.level === 1 && "font-bold"
|
|
||||||
)}>
|
|
||||||
{category.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
</CommandList>
|
|
||||||
</Command>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">Description</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
name="description"
|
|
||||||
value={formData.description || ""}
|
|
||||||
onChange={handleTextAreaChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-2xl font-bold">Import Templates</h2>
|
<h2 className="text-2xl font-bold">Import Templates</h2>
|
||||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
<div className="flex gap-2">
|
||||||
<DialogTrigger asChild>
|
<Button onClick={() => setIsCreateOpen(true)}>
|
||||||
<Button>Create Template</Button>
|
Create Blank Template
|
||||||
</DialogTrigger>
|
</Button>
|
||||||
<DialogContent className="max-w-2xl max-h-[95vh] flex flex-col p-6">
|
<Button onClick={() => setIsSearchOpen(true)}>
|
||||||
<DialogHeader className="px-0">
|
Create from Product
|
||||||
<DialogTitle>Create Import Template</DialogTitle>
|
</Button>
|
||||||
<DialogDescription>
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -1044,35 +352,28 @@ export function TemplateManagement() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dialog open={isEditOpen} onOpenChange={(open) => {
|
{/* Template Form for Create/Edit/Copy */}
|
||||||
if (!open) {
|
<TemplateForm
|
||||||
|
isOpen={isCreateOpen || (editingTemplate !== null)}
|
||||||
|
onClose={() => {
|
||||||
|
setIsCreateOpen(false);
|
||||||
setEditingTemplate(null);
|
setEditingTemplate(null);
|
||||||
setFormData({ company: "", product_type: "" });
|
}}
|
||||||
}
|
onSuccess={handleTemplateSuccess}
|
||||||
setIsEditOpen(open);
|
initialData={editingTemplate || undefined}
|
||||||
}}>
|
mode={editingTemplate ? (editingTemplate.id ? 'edit' : 'copy') : 'create'}
|
||||||
<DialogContent className="max-w-2xl max-h-[95vh] flex flex-col p-6">
|
templateId={editingTemplate?.id}
|
||||||
<DialogHeader className="px-0">
|
fieldOptions={fieldOptions}
|
||||||
<DialogTitle>Edit Template</DialogTitle>
|
/>
|
||||||
<DialogDescription>
|
|
||||||
Edit the template details. Company and Product Type combination must be unique.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1 overflow-hidden">
|
|
||||||
<ScrollArea className="flex-1 overflow-y-auto">
|
|
||||||
<div className="pr-4">
|
|
||||||
{renderFormFields()}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
<DialogFooter className="px-0 mt-4">
|
|
||||||
<Button type="submit" disabled={createMutation.isPending}>
|
|
||||||
{createMutation.isPending ? "Updating..." : "Update Template"}
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
|
{/* Product Search Dialog */}
|
||||||
|
<SearchProductTemplateDialog
|
||||||
|
isOpen={isSearchOpen}
|
||||||
|
onClose={() => setIsSearchOpen(false)}
|
||||||
|
onTemplateCreated={handleTemplateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
<AlertDialog open={isDeleteOpen} onOpenChange={setIsDeleteOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
|
|||||||
@@ -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 { AiValidationDialogs } from './AiValidationDialogs'
|
||||||
import config from '@/config'
|
import config from '@/config'
|
||||||
import { Fields } from '../../../types'
|
import { Fields } from '../../../types'
|
||||||
|
import { SearchProductTemplateDialog } from '@/components/templates/SearchProductTemplateDialog'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ValidationContainer component - the main wrapper for the validation step
|
* ValidationContainer component - the main wrapper for the validation step
|
||||||
@@ -950,7 +951,7 @@ const ValidationContainer = <T extends string>({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Product Search Dialog */}
|
{/* Product Search Dialog */}
|
||||||
<ProductSearchDialog
|
<SearchProductTemplateDialog
|
||||||
isOpen={isProductSearchDialogOpen}
|
isOpen={isProductSearchDialogOpen}
|
||||||
onClose={() => setIsProductSearchDialogOpen(false)}
|
onClose={() => setIsProductSearchDialogOpen(false)}
|
||||||
onTemplateCreated={loadTemplates}
|
onTemplateCreated={loadTemplates}
|
||||||
|
|||||||
Reference in New Issue
Block a user