Add/fix filters and styling for product page, add drag and drop
This commit is contained in:
@@ -70,6 +70,23 @@ router.get('/', async (req, res) => {
|
||||
params.push(parseFloat(req.query.maxStock));
|
||||
}
|
||||
|
||||
if (req.query.daysOfStock) {
|
||||
conditions.push('pm.days_of_inventory >= ?');
|
||||
params.push(parseFloat(req.query.daysOfStock));
|
||||
}
|
||||
|
||||
// Handle boolean filters
|
||||
if (req.query.replenishable === 'true' || req.query.replenishable === 'false') {
|
||||
conditions.push('p.replenishable = ?');
|
||||
params.push(req.query.replenishable === 'true');
|
||||
}
|
||||
|
||||
if (req.query.managingStock === 'true' || req.query.managingStock === 'false') {
|
||||
conditions.push('p.managing_stock = ?');
|
||||
params.push(req.query.managingStock === 'true');
|
||||
}
|
||||
|
||||
// Handle price filters
|
||||
if (req.query.minPrice) {
|
||||
conditions.push('p.price >= ?');
|
||||
params.push(parseFloat(req.query.minPrice));
|
||||
@@ -80,6 +97,27 @@ router.get('/', async (req, res) => {
|
||||
params.push(parseFloat(req.query.maxPrice));
|
||||
}
|
||||
|
||||
if (req.query.minCostPrice) {
|
||||
conditions.push('p.cost_price >= ?');
|
||||
params.push(parseFloat(req.query.minCostPrice));
|
||||
}
|
||||
|
||||
if (req.query.maxCostPrice) {
|
||||
conditions.push('p.cost_price <= ?');
|
||||
params.push(parseFloat(req.query.maxCostPrice));
|
||||
}
|
||||
|
||||
if (req.query.minLandingCost) {
|
||||
conditions.push('p.landing_cost_price >= ?');
|
||||
params.push(parseFloat(req.query.minLandingCost));
|
||||
}
|
||||
|
||||
if (req.query.maxLandingCost) {
|
||||
conditions.push('p.landing_cost_price <= ?');
|
||||
params.push(parseFloat(req.query.maxLandingCost));
|
||||
}
|
||||
|
||||
// Handle sales metrics filters
|
||||
if (req.query.minSalesAvg) {
|
||||
conditions.push('pm.daily_sales_avg >= ?');
|
||||
params.push(parseFloat(req.query.minSalesAvg));
|
||||
@@ -90,6 +128,27 @@ router.get('/', async (req, res) => {
|
||||
params.push(parseFloat(req.query.maxSalesAvg));
|
||||
}
|
||||
|
||||
if (req.query.minWeeklySales) {
|
||||
conditions.push('pm.weekly_sales_avg >= ?');
|
||||
params.push(parseFloat(req.query.minWeeklySales));
|
||||
}
|
||||
|
||||
if (req.query.maxWeeklySales) {
|
||||
conditions.push('pm.weekly_sales_avg <= ?');
|
||||
params.push(parseFloat(req.query.maxWeeklySales));
|
||||
}
|
||||
|
||||
if (req.query.minMonthlySales) {
|
||||
conditions.push('pm.monthly_sales_avg >= ?');
|
||||
params.push(parseFloat(req.query.minMonthlySales));
|
||||
}
|
||||
|
||||
if (req.query.maxMonthlySales) {
|
||||
conditions.push('pm.monthly_sales_avg <= ?');
|
||||
params.push(parseFloat(req.query.maxMonthlySales));
|
||||
}
|
||||
|
||||
// Handle financial metrics filters
|
||||
if (req.query.minMargin) {
|
||||
conditions.push('pm.avg_margin_percent >= ?');
|
||||
params.push(parseFloat(req.query.minMargin));
|
||||
@@ -110,6 +169,32 @@ router.get('/', async (req, res) => {
|
||||
params.push(parseFloat(req.query.maxGMROI));
|
||||
}
|
||||
|
||||
// Handle lead time and coverage filters
|
||||
if (req.query.minLeadTime) {
|
||||
conditions.push('pm.avg_lead_time_days >= ?');
|
||||
params.push(parseFloat(req.query.minLeadTime));
|
||||
}
|
||||
|
||||
if (req.query.maxLeadTime) {
|
||||
conditions.push('pm.avg_lead_time_days <= ?');
|
||||
params.push(parseFloat(req.query.maxLeadTime));
|
||||
}
|
||||
|
||||
if (req.query.leadTimeStatus) {
|
||||
conditions.push('pm.lead_time_status = ?');
|
||||
params.push(req.query.leadTimeStatus);
|
||||
}
|
||||
|
||||
if (req.query.minStockCoverage) {
|
||||
conditions.push('(pm.days_of_inventory / pt.target_days) >= ?');
|
||||
params.push(parseFloat(req.query.minStockCoverage));
|
||||
}
|
||||
|
||||
if (req.query.maxStockCoverage) {
|
||||
conditions.push('(pm.days_of_inventory / pt.target_days) <= ?');
|
||||
params.push(parseFloat(req.query.maxStockCoverage));
|
||||
}
|
||||
|
||||
// Handle status filters
|
||||
if (req.query.stockStatus && req.query.stockStatus !== 'all') {
|
||||
conditions.push('pm.stock_status = ?');
|
||||
@@ -208,10 +293,10 @@ router.get('/', async (req, res) => {
|
||||
res.json({
|
||||
products,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
currentPage: page,
|
||||
pages: Math.ceil(total / limit),
|
||||
limit
|
||||
},
|
||||
filters: {
|
||||
categories: categories.map(category => category.name),
|
||||
|
||||
56
inventory/package-lock.json
generated
56
inventory/package-lock.json
generated
@@ -8,6 +8,9 @@
|
||||
"name": "inventory",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
@@ -386,6 +389,59 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.24.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-collapsible": "^1.1.2",
|
||||
|
||||
@@ -3,15 +3,16 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/components/ui/command";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { X, Plus } from "lucide-react";
|
||||
import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type FilterValue = string | number | boolean;
|
||||
|
||||
@@ -44,24 +45,43 @@ const FILTER_OPTIONS: FilterOption[] = [
|
||||
label: 'Stock Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Critical', value: 'critical' },
|
||||
{ label: 'Reorder', value: 'reorder' },
|
||||
{ label: 'Healthy', value: 'healthy' },
|
||||
{ label: 'Overstocked', value: 'overstocked' },
|
||||
{ label: 'New', value: 'new' },
|
||||
{ label: 'Critical', value: 'Critical' },
|
||||
{ label: 'Reorder', value: 'Reorder' },
|
||||
{ label: 'Healthy', value: 'Healthy' },
|
||||
{ label: 'Overstocked', value: 'Overstocked' },
|
||||
{ label: 'New', value: 'New' }
|
||||
],
|
||||
group: 'Inventory'
|
||||
},
|
||||
{ id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' },
|
||||
{ id: 'maxStock', label: 'Max Stock', type: 'number', group: 'Inventory' },
|
||||
{ id: 'daysOfStock', label: 'Days of Stock', type: 'number', group: 'Inventory' },
|
||||
{
|
||||
id: 'replenishable',
|
||||
label: 'Replenishable',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Yes', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
],
|
||||
group: 'Inventory'
|
||||
},
|
||||
|
||||
// Pricing Group
|
||||
{ id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' },
|
||||
{ id: 'maxPrice', label: 'Max Price', type: 'number', group: 'Pricing' },
|
||||
{ id: 'minCostPrice', label: 'Min Cost Price', type: 'number', group: 'Pricing' },
|
||||
{ id: 'maxCostPrice', label: 'Max Cost Price', type: 'number', group: 'Pricing' },
|
||||
{ id: 'minLandingCost', label: 'Min Landing Cost', type: 'number', group: 'Pricing' },
|
||||
{ id: 'maxLandingCost', label: 'Max Landing Cost', type: 'number', group: 'Pricing' },
|
||||
|
||||
// Sales Metrics Group
|
||||
{ id: 'minSalesAvg', label: 'Min Daily Sales Avg', type: 'number', group: 'Sales Metrics' },
|
||||
{ id: 'maxSalesAvg', label: 'Max Daily Sales Avg', type: 'number', group: 'Sales Metrics' },
|
||||
{ id: 'minWeeklySales', label: 'Min Weekly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
||||
{ id: 'maxWeeklySales', label: 'Max Weekly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
||||
{ id: 'minMonthlySales', label: 'Min Monthly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
||||
{ id: 'maxMonthlySales', label: 'Max Monthly Sales Avg', type: 'number', group: 'Sales Metrics' },
|
||||
|
||||
// Financial Metrics Group
|
||||
{ id: 'minMargin', label: 'Min Margin %', type: 'number', group: 'Financial Metrics' },
|
||||
@@ -69,6 +89,23 @@ const FILTER_OPTIONS: FilterOption[] = [
|
||||
{ id: 'minGMROI', label: 'Min GMROI', type: 'number', group: 'Financial Metrics' },
|
||||
{ id: 'maxGMROI', label: 'Max GMROI', type: 'number', group: 'Financial Metrics' },
|
||||
|
||||
// Lead Time & Stock Coverage Group
|
||||
{ id: 'minLeadTime', label: 'Min Lead Time (Days)', type: 'number', group: 'Lead Time & Coverage' },
|
||||
{ id: 'maxLeadTime', label: 'Max Lead Time (Days)', type: 'number', group: 'Lead Time & Coverage' },
|
||||
{
|
||||
id: 'leadTimeStatus',
|
||||
label: 'Lead Time Status',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'On Target', value: 'on_target' },
|
||||
{ label: 'Warning', value: 'warning' },
|
||||
{ label: 'Critical', value: 'critical' }
|
||||
],
|
||||
group: 'Lead Time & Coverage'
|
||||
},
|
||||
{ id: 'minStockCoverage', label: 'Min Stock Coverage Ratio', type: 'number', group: 'Lead Time & Coverage' },
|
||||
{ id: 'maxStockCoverage', label: 'Max Stock Coverage Ratio', type: 'number', group: 'Lead Time & Coverage' },
|
||||
|
||||
// Classification Group
|
||||
{
|
||||
id: 'abcClass',
|
||||
@@ -77,10 +114,20 @@ const FILTER_OPTIONS: FilterOption[] = [
|
||||
options: [
|
||||
{ label: 'A', value: 'A' },
|
||||
{ label: 'B', value: 'B' },
|
||||
{ label: 'C', value: 'C' },
|
||||
{ label: 'C', value: 'C' }
|
||||
],
|
||||
group: 'Classification'
|
||||
},
|
||||
{
|
||||
id: 'managingStock',
|
||||
label: 'Managing Stock',
|
||||
type: 'select',
|
||||
options: [
|
||||
{ label: 'Yes', value: 'true' },
|
||||
{ label: 'No', value: 'false' }
|
||||
],
|
||||
group: 'Classification'
|
||||
}
|
||||
];
|
||||
|
||||
interface ProductFiltersProps {
|
||||
@@ -98,9 +145,30 @@ export function ProductFilters({
|
||||
onClearFilters,
|
||||
activeFilters,
|
||||
}: ProductFiltersProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [showCommand, setShowCommand] = React.useState(false);
|
||||
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
|
||||
const [filterValue, setFilterValue] = React.useState<string>("");
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const [searchValue, setSearchValue] = React.useState("");
|
||||
|
||||
// Handle escape key
|
||||
React.useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
if (selectedFilter) {
|
||||
setSelectedFilter(null);
|
||||
setInputValue("");
|
||||
} else {
|
||||
setShowCommand(false);
|
||||
setSearchValue("");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (showCommand) {
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [showCommand, selectedFilter]);
|
||||
|
||||
// Update filter options with dynamic data
|
||||
const filterOptions = React.useMemo(() => {
|
||||
@@ -121,6 +189,56 @@ export function ProductFilters({
|
||||
});
|
||||
}, [categories, vendors]);
|
||||
|
||||
// Filter options based on search
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
if (!searchValue) return filterOptions;
|
||||
|
||||
const search = searchValue.toLowerCase();
|
||||
return filterOptions.filter(option =>
|
||||
option.label.toLowerCase().includes(search) ||
|
||||
option.group.toLowerCase().includes(search)
|
||||
);
|
||||
}, [filterOptions, searchValue]);
|
||||
|
||||
const handleSelectFilter = React.useCallback((filter: FilterOption) => {
|
||||
setSelectedFilter(filter);
|
||||
setInputValue("");
|
||||
}, []);
|
||||
|
||||
const handleApplyFilter = (value: FilterValue) => {
|
||||
if (!selectedFilter) return;
|
||||
|
||||
const newFilters = {
|
||||
...activeFilters,
|
||||
[selectedFilter.id]: value
|
||||
};
|
||||
|
||||
onFilterChange(newFilters);
|
||||
setSelectedFilter(null);
|
||||
setInputValue("");
|
||||
setSearchValue("");
|
||||
};
|
||||
|
||||
const handleKeyDown = React.useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && selectedFilter) {
|
||||
if (selectedFilter.type === 'select') {
|
||||
const option = selectedFilter.options?.find(opt =>
|
||||
opt.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
);
|
||||
if (option) {
|
||||
handleApplyFilter(option.value);
|
||||
}
|
||||
} else if (selectedFilter.type === 'number') {
|
||||
const numValue = parseFloat(inputValue);
|
||||
if (!isNaN(numValue)) {
|
||||
handleApplyFilter(numValue);
|
||||
}
|
||||
} else if (selectedFilter.type === 'text' && inputValue.trim() !== '') {
|
||||
handleApplyFilter(inputValue.trim());
|
||||
}
|
||||
}
|
||||
}, [selectedFilter, inputValue]);
|
||||
|
||||
const activeFiltersList = React.useMemo(() => {
|
||||
if (!activeFilters) return [];
|
||||
|
||||
@@ -142,50 +260,46 @@ export function ProductFilters({
|
||||
});
|
||||
}, [activeFilters, filterOptions]);
|
||||
|
||||
const handleSelectFilter = (filter: FilterOption) => {
|
||||
setSelectedFilter(filter);
|
||||
if (filter.type === 'select') {
|
||||
setOpen(false);
|
||||
// Open a new command dialog for selecting the value
|
||||
// This will be handled in a separate component
|
||||
} else {
|
||||
// For other types, we'll show an input field
|
||||
setFilterValue("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFilter = (filterId: string) => {
|
||||
const newFilters = { ...activeFilters };
|
||||
delete newFilters[filterId];
|
||||
onFilterChange(newFilters);
|
||||
};
|
||||
|
||||
const handleApplyFilter = (value: FilterValue) => {
|
||||
if (!selectedFilter) return;
|
||||
|
||||
const newFilters = {
|
||||
...activeFilters,
|
||||
[selectedFilter.id]: value
|
||||
};
|
||||
|
||||
onFilterChange(newFilters);
|
||||
setSelectedFilter(null);
|
||||
setFilterValue("");
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 border-dashed"
|
||||
onClick={() => setOpen(true)}
|
||||
onClick={() => {
|
||||
setShowCommand(true);
|
||||
setSelectedFilter(null);
|
||||
setInputValue("");
|
||||
setSearchValue("");
|
||||
}}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Filter
|
||||
</Button>
|
||||
{activeFiltersList.map((filter) => (
|
||||
<Badge
|
||||
key={filter.id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span>
|
||||
{filter.label}: {filter.displayValue}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => {
|
||||
const newFilters = { ...activeFilters };
|
||||
delete newFilters[filter.id];
|
||||
onFilterChange(newFilters);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
{activeFiltersList.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -193,89 +307,182 @@ export function ProductFilters({
|
||||
className="h-8"
|
||||
onClick={onClearFilters}
|
||||
>
|
||||
Clear Filters
|
||||
Clear All
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeFiltersList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{activeFiltersList.map((filter) => (
|
||||
<Badge
|
||||
key={filter.id}
|
||||
variant="secondary"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span>
|
||||
{filter.label}: {filter.displayValue}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||
onClick={() => handleRemoveFilter(filter.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<Command className="rounded-lg border shadow-md">
|
||||
<DialogTitle className="sr-only">Search Filters</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search and select filters to apply to the product list
|
||||
</DialogDescription>
|
||||
<CommandInput placeholder="Search filters..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No filters found.</CommandEmpty>
|
||||
{Object.entries(
|
||||
filterOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
|
||||
if (!acc[filter.group]) acc[filter.group] = [];
|
||||
acc[filter.group].push(filter);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([group, filters]) => (
|
||||
<CommandGroup key={group} heading={group}>
|
||||
{filters.map((filter) => (
|
||||
<CommandItem
|
||||
key={filter.id}
|
||||
onSelect={() => handleSelectFilter(filter)}
|
||||
>
|
||||
{filter.label}
|
||||
</CommandItem>
|
||||
{showCommand && (
|
||||
<Command
|
||||
className="rounded-lg border"
|
||||
shouldFilter={false}
|
||||
>
|
||||
{!selectedFilter ? (
|
||||
<>
|
||||
<CommandInput
|
||||
placeholder="Search and select filters..."
|
||||
value={searchValue}
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No filters found.</CommandEmpty>
|
||||
{Object.entries(
|
||||
filteredOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
|
||||
if (!acc[filter.group]) acc[filter.group] = [];
|
||||
acc[filter.group].push(filter);
|
||||
return acc;
|
||||
}, {})
|
||||
).map(([group, filters]) => (
|
||||
<React.Fragment key={group}>
|
||||
<CommandGroup heading={group}>
|
||||
{filters.map((filter) => (
|
||||
<CommandItem
|
||||
key={filter.id}
|
||||
value={`${filter.id} ${filter.label}`}
|
||||
onSelect={() => {
|
||||
handleSelectFilter(filter);
|
||||
if (filter.type !== 'select') {
|
||||
setInputValue("");
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
activeFilters?.[filter.id] && "bg-accent"
|
||||
)}
|
||||
>
|
||||
{filter.label}
|
||||
{activeFilters?.[filter.id] && (
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</CommandList>
|
||||
</CommandList>
|
||||
</>
|
||||
) : selectedFilter.type === 'select' ? (
|
||||
<>
|
||||
<CommandInput
|
||||
placeholder={`Select ${selectedFilter.label.toLowerCase()}...`}
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Backspace' && !inputValue) {
|
||||
e.preventDefault();
|
||||
setSelectedFilter(null);
|
||||
} else {
|
||||
handleKeyDown(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setSelectedFilter(null);
|
||||
setInputValue("");
|
||||
}}
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
← Back to filters
|
||||
</CommandItem>
|
||||
<CommandSeparator />
|
||||
{selectedFilter.options
|
||||
?.filter(option =>
|
||||
option.label.toLowerCase().includes(inputValue.toLowerCase())
|
||||
)
|
||||
.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={() => {
|
||||
handleApplyFilter(option.value);
|
||||
setShowCommand(false);
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))
|
||||
}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CommandInput
|
||||
placeholder={`Enter ${selectedFilter.label.toLowerCase()}`}
|
||||
value={inputValue}
|
||||
onValueChange={(value) => {
|
||||
if (selectedFilter.type === 'number') {
|
||||
if (/^\d*\.?\d*$/.test(value)) {
|
||||
setInputValue(value);
|
||||
}
|
||||
} else {
|
||||
setInputValue(value);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Backspace' && !inputValue) {
|
||||
e.preventDefault();
|
||||
setSelectedFilter(null);
|
||||
} else if (e.key === 'Enter') {
|
||||
if (selectedFilter.type === 'number') {
|
||||
const numValue = parseFloat(inputValue);
|
||||
if (!isNaN(numValue)) {
|
||||
handleApplyFilter(numValue);
|
||||
setShowCommand(false);
|
||||
}
|
||||
} else {
|
||||
if (inputValue.trim() !== '') {
|
||||
handleApplyFilter(inputValue.trim());
|
||||
setShowCommand(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
setSelectedFilter(null);
|
||||
setInputValue("");
|
||||
}}
|
||||
className="cursor-pointer text-muted-foreground"
|
||||
>
|
||||
← Back to filters
|
||||
</CommandItem>
|
||||
<CommandSeparator />
|
||||
<CommandItem
|
||||
onSelect={() => {
|
||||
if (selectedFilter.type === 'number') {
|
||||
const numValue = parseFloat(inputValue);
|
||||
if (!isNaN(numValue)) {
|
||||
handleApplyFilter(numValue);
|
||||
setShowCommand(false);
|
||||
}
|
||||
} else {
|
||||
if (inputValue.trim() !== '') {
|
||||
handleApplyFilter(inputValue.trim());
|
||||
setShowCommand(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Apply filter: {inputValue}
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</>
|
||||
)}
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
|
||||
{selectedFilter?.type === 'select' && (
|
||||
<CommandDialog open={!!selectedFilter} onOpenChange={() => setSelectedFilter(null)}>
|
||||
<Command className="rounded-lg border shadow-md">
|
||||
<DialogTitle className="sr-only">Select {selectedFilter.label}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Choose a value for the {selectedFilter.label.toLowerCase()} filter
|
||||
</DialogDescription>
|
||||
<CommandInput placeholder={`Select ${selectedFilter.label.toLowerCase()}...`} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No options found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{selectedFilter.options?.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => handleApplyFilter(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</CommandDialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ArrowUpDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ArrowUpDown, GripVertical } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
@@ -10,6 +11,24 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface Product {
|
||||
product_id: string;
|
||||
@@ -62,10 +81,12 @@ interface Product {
|
||||
}
|
||||
|
||||
interface ColumnDef {
|
||||
key: keyof Product;
|
||||
key: keyof Product | 'image';
|
||||
label: string;
|
||||
group: string;
|
||||
format?: (value: any) => string | number;
|
||||
width?: string;
|
||||
noLabel?: boolean;
|
||||
}
|
||||
|
||||
interface ProductTableProps {
|
||||
@@ -75,6 +96,62 @@ interface ProductTableProps {
|
||||
sortDirection: 'asc' | 'desc';
|
||||
visibleColumns: Set<keyof Product>;
|
||||
columnDefs: ColumnDef[];
|
||||
onColumnOrderChange?: (columns: (keyof Product)[]) => void;
|
||||
}
|
||||
|
||||
interface SortableHeaderProps {
|
||||
column: keyof Product;
|
||||
columnDef?: ColumnDef;
|
||||
onSort: (column: keyof Product) => void;
|
||||
sortColumn: keyof Product;
|
||||
sortDirection: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }: SortableHeaderProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: column });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
if (columnDef?.noLabel) {
|
||||
return <TableHead ref={setNodeRef} style={style} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableHead
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
"cursor-pointer select-none",
|
||||
columnDef?.width
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div {...attributes} {...listeners} className="cursor-grab">
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2" onClick={() => onSort(column)}>
|
||||
{columnDef?.label ?? column}
|
||||
{sortColumn === column && (
|
||||
<ArrowUpDown className={cn(
|
||||
"h-4 w-4",
|
||||
sortDirection === 'desc' && "rotate-180 transform"
|
||||
)} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProductTable({
|
||||
@@ -84,7 +161,45 @@ export function ProductTable({
|
||||
sortDirection,
|
||||
visibleColumns,
|
||||
columnDefs,
|
||||
onColumnOrderChange,
|
||||
}: ProductTableProps) {
|
||||
const [activeId, setActiveId] = React.useState<keyof Product | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
activationConstraint: {
|
||||
delay: 200,
|
||||
tolerance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Get ordered visible columns
|
||||
const orderedColumns = React.useMemo(() => {
|
||||
return Array.from(visibleColumns);
|
||||
}, [visibleColumns]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as keyof Product);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = orderedColumns.indexOf(active.id as keyof Product);
|
||||
const newIndex = orderedColumns.indexOf(over.id as keyof Product);
|
||||
|
||||
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
|
||||
onColumnOrderChange?.(newOrder);
|
||||
}
|
||||
};
|
||||
|
||||
const getSortIcon = (column: keyof Product) => {
|
||||
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
|
||||
return (
|
||||
@@ -140,19 +255,24 @@ export function ProductTable({
|
||||
}
|
||||
};
|
||||
|
||||
const formatColumnValue = (product: Product, column: ColumnDef) => {
|
||||
const value = product[column.key];
|
||||
const formatColumnValue = (product: Product, column: keyof Product | 'image') => {
|
||||
const value = column === 'image' ? product.image : product[column as keyof Product];
|
||||
const columnDef = columnDefs.find(def => def.key === column);
|
||||
|
||||
// Special formatting for specific columns
|
||||
switch (column.key) {
|
||||
switch (column) {
|
||||
case 'image':
|
||||
return product.image ? (
|
||||
<img
|
||||
src={product.image}
|
||||
alt={product.title}
|
||||
className="h-12 w-12 object-contain bg-white rounded border"
|
||||
/>
|
||||
) : null;
|
||||
case 'title':
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={product.image || undefined} alt={product.title} />
|
||||
<AvatarFallback>{product.title.charAt(0).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium">{value as string}</span>
|
||||
<div className="min-w-[300px]">
|
||||
<div className="font-medium">{product.title}</div>
|
||||
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||
</div>
|
||||
);
|
||||
case 'categories':
|
||||
@@ -176,51 +296,55 @@ export function ProductTable({
|
||||
<Badge variant="outline">Hidden</Badge>
|
||||
);
|
||||
default:
|
||||
if (column.format && value !== undefined && value !== null) {
|
||||
return column.format(value);
|
||||
if (columnDef?.format && value !== undefined && value !== null) {
|
||||
return columnDef.format(value);
|
||||
}
|
||||
return value || '-';
|
||||
}
|
||||
};
|
||||
|
||||
// Get visible column definitions in order
|
||||
const visibleColumnDefs = columnDefs.filter(col => visibleColumns.has(col.key));
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{visibleColumnDefs.map((column) => (
|
||||
<TableHead key={column.key}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => onSort(column.key)}
|
||||
>
|
||||
{column.label}
|
||||
{getSortIcon(column.key)}
|
||||
</Button>
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{products.map((product) => {
|
||||
console.log('Rendering product:', product.product_id, product.title, product.categories);
|
||||
return (
|
||||
<TableRow key={product.product_id}>
|
||||
{visibleColumnDefs.map((column) => (
|
||||
<TableCell key={`${product.product_id}-${column.key}`}>
|
||||
{formatColumnValue(product, column)}
|
||||
</TableCell>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<SortableContext
|
||||
items={orderedColumns}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{orderedColumns.map((column) => (
|
||||
<SortableHeader
|
||||
key={column}
|
||||
column={column}
|
||||
columnDef={columnDefs.find(def => def.key === column)}
|
||||
onSort={onSort}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
/>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</SortableContext>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
</DndContext>
|
||||
<TableBody>
|
||||
{products.map((product) => (
|
||||
<TableRow key={product.product_id}>
|
||||
{orderedColumns.map((column) => (
|
||||
<TableCell key={`${product.product_id}-${column}`}>
|
||||
{formatColumnValue(product, column)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
{!products.length && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={visibleColumnDefs.length}
|
||||
colSpan={orderedColumns.length}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
No products found
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { ProductFilters } from '@/components/products/ProductFilters';
|
||||
import { ProductTable } from '@/components/products/ProductTable';
|
||||
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
||||
import debounce from 'lodash/debounce';
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
@@ -76,14 +75,6 @@ interface Product {
|
||||
lead_time_status?: string;
|
||||
}
|
||||
|
||||
interface ProductFiltersState {
|
||||
search: string;
|
||||
category: string;
|
||||
vendor: string;
|
||||
stockStatus: string;
|
||||
minPrice: string;
|
||||
maxPrice: string;
|
||||
}
|
||||
|
||||
// Column definition interface
|
||||
interface ColumnDef {
|
||||
@@ -169,6 +160,7 @@ export function Products() {
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
const [page, setPage] = useState(1);
|
||||
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product>>(new Set(DEFAULT_VISIBLE_COLUMNS));
|
||||
const [columnOrder, setColumnOrder] = useState<(keyof Product)[]>(DEFAULT_VISIBLE_COLUMNS);
|
||||
|
||||
// Group columns by their group property
|
||||
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||
@@ -283,6 +275,20 @@ export function Products() {
|
||||
setPage(newPage);
|
||||
};
|
||||
|
||||
// Update column order when visibility changes
|
||||
useEffect(() => {
|
||||
setColumnOrder(prev => {
|
||||
const newOrder = prev.filter(col => visibleColumns.has(col));
|
||||
const newColumns = Array.from(visibleColumns).filter(col => !prev.includes(col));
|
||||
return [...newOrder, ...newColumns];
|
||||
});
|
||||
}, [visibleColumns]);
|
||||
|
||||
// Handle column reordering
|
||||
const handleColumnOrderChange = (newOrder: (keyof Product)[]) => {
|
||||
setColumnOrder(newOrder);
|
||||
};
|
||||
|
||||
const renderPagination = () => {
|
||||
if (!data?.pagination.pages || data.pagination.pages <= 1) return null;
|
||||
|
||||
@@ -408,20 +414,18 @@ export function Products() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductFilters
|
||||
filters={filters}
|
||||
categories={data?.filters.categories ?? []}
|
||||
vendors={data?.filters.vendors ?? []}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
activeFilters={filters}
|
||||
/>
|
||||
|
||||
<div ref={tableRef}>
|
||||
{isLoading ? (
|
||||
<ProductTableSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<ProductFilters
|
||||
categories={data?.filters?.categories ?? []}
|
||||
vendors={data?.filters?.vendors ?? []}
|
||||
onFilterChange={handleFilterChange}
|
||||
onClearFilters={handleClearFilters}
|
||||
activeFilters={filters}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
{isLoading ? (
|
||||
<ProductTableSkeleton />
|
||||
) : (
|
||||
<div className="relative">
|
||||
{isFetching && (
|
||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||
@@ -430,16 +434,19 @@ export function Products() {
|
||||
)}
|
||||
<ProductTable
|
||||
products={data?.products ?? []}
|
||||
onSort={handleSort}
|
||||
visibleColumns={new Set(columnOrder)}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
visibleColumns={visibleColumns}
|
||||
columnDefs={AVAILABLE_COLUMNS}
|
||||
onSort={handleSort}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
/>
|
||||
</div>
|
||||
{renderPagination()}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{renderPagination()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user