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));
|
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) {
|
if (req.query.minPrice) {
|
||||||
conditions.push('p.price >= ?');
|
conditions.push('p.price >= ?');
|
||||||
params.push(parseFloat(req.query.minPrice));
|
params.push(parseFloat(req.query.minPrice));
|
||||||
@@ -80,6 +97,27 @@ router.get('/', async (req, res) => {
|
|||||||
params.push(parseFloat(req.query.maxPrice));
|
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) {
|
if (req.query.minSalesAvg) {
|
||||||
conditions.push('pm.daily_sales_avg >= ?');
|
conditions.push('pm.daily_sales_avg >= ?');
|
||||||
params.push(parseFloat(req.query.minSalesAvg));
|
params.push(parseFloat(req.query.minSalesAvg));
|
||||||
@@ -90,6 +128,27 @@ router.get('/', async (req, res) => {
|
|||||||
params.push(parseFloat(req.query.maxSalesAvg));
|
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) {
|
if (req.query.minMargin) {
|
||||||
conditions.push('pm.avg_margin_percent >= ?');
|
conditions.push('pm.avg_margin_percent >= ?');
|
||||||
params.push(parseFloat(req.query.minMargin));
|
params.push(parseFloat(req.query.minMargin));
|
||||||
@@ -110,6 +169,32 @@ router.get('/', async (req, res) => {
|
|||||||
params.push(parseFloat(req.query.maxGMROI));
|
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
|
// Handle status filters
|
||||||
if (req.query.stockStatus && req.query.stockStatus !== 'all') {
|
if (req.query.stockStatus && req.query.stockStatus !== 'all') {
|
||||||
conditions.push('pm.stock_status = ?');
|
conditions.push('pm.stock_status = ?');
|
||||||
@@ -208,10 +293,10 @@ router.get('/', async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
products,
|
products,
|
||||||
pagination: {
|
pagination: {
|
||||||
page,
|
|
||||||
limit,
|
|
||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / limit)
|
currentPage: page,
|
||||||
|
pages: Math.ceil(total / limit),
|
||||||
|
limit
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
categories: categories.map(category => category.name),
|
categories: categories.map(category => category.name),
|
||||||
|
|||||||
56
inventory/package-lock.json
generated
56
inventory/package-lock.json
generated
@@ -8,6 +8,9 @@
|
|||||||
"name": "inventory",
|
"name": "inventory",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"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-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-collapsible": "^1.1.2",
|
"@radix-ui/react-collapsible": "^1.1.2",
|
||||||
@@ -386,6 +389,59 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.24.2",
|
"version": "0.24.2",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-collapsible": "^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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Command,
|
Command,
|
||||||
CommandDialog,
|
|
||||||
CommandEmpty,
|
CommandEmpty,
|
||||||
CommandGroup,
|
CommandGroup,
|
||||||
CommandInput,
|
CommandInput,
|
||||||
CommandItem,
|
CommandItem,
|
||||||
CommandList,
|
CommandList,
|
||||||
|
CommandSeparator,
|
||||||
} from "@/components/ui/command";
|
} from "@/components/ui/command";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { X, Plus } from "lucide-react";
|
import { X, Plus } from "lucide-react";
|
||||||
import { DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
type FilterValue = string | number | boolean;
|
type FilterValue = string | number | boolean;
|
||||||
|
|
||||||
@@ -44,24 +45,43 @@ const FILTER_OPTIONS: FilterOption[] = [
|
|||||||
label: 'Stock Status',
|
label: 'Stock Status',
|
||||||
type: 'select',
|
type: 'select',
|
||||||
options: [
|
options: [
|
||||||
{ label: 'Critical', value: 'critical' },
|
{ label: 'Critical', value: 'Critical' },
|
||||||
{ label: 'Reorder', value: 'reorder' },
|
{ label: 'Reorder', value: 'Reorder' },
|
||||||
{ label: 'Healthy', value: 'healthy' },
|
{ label: 'Healthy', value: 'Healthy' },
|
||||||
{ label: 'Overstocked', value: 'overstocked' },
|
{ label: 'Overstocked', value: 'Overstocked' },
|
||||||
{ label: 'New', value: 'new' },
|
{ label: 'New', value: 'New' }
|
||||||
],
|
],
|
||||||
group: 'Inventory'
|
group: 'Inventory'
|
||||||
},
|
},
|
||||||
{ id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' },
|
{ id: 'minStock', label: 'Min Stock', type: 'number', group: 'Inventory' },
|
||||||
{ id: 'maxStock', label: 'Max 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
|
// Pricing Group
|
||||||
{ id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' },
|
{ id: 'minPrice', label: 'Min Price', type: 'number', group: 'Pricing' },
|
||||||
{ id: 'maxPrice', label: 'Max 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
|
// Sales Metrics Group
|
||||||
{ id: 'minSalesAvg', label: 'Min Daily Sales Avg', type: 'number', group: 'Sales Metrics' },
|
{ 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: '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
|
// Financial Metrics Group
|
||||||
{ id: 'minMargin', label: 'Min Margin %', type: 'number', group: 'Financial Metrics' },
|
{ 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: 'minGMROI', label: 'Min GMROI', type: 'number', group: 'Financial Metrics' },
|
||||||
{ id: 'maxGMROI', label: 'Max 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
|
// Classification Group
|
||||||
{
|
{
|
||||||
id: 'abcClass',
|
id: 'abcClass',
|
||||||
@@ -77,10 +114,20 @@ const FILTER_OPTIONS: FilterOption[] = [
|
|||||||
options: [
|
options: [
|
||||||
{ label: 'A', value: 'A' },
|
{ label: 'A', value: 'A' },
|
||||||
{ label: 'B', value: 'B' },
|
{ label: 'B', value: 'B' },
|
||||||
{ label: 'C', value: 'C' },
|
{ label: 'C', value: 'C' }
|
||||||
],
|
],
|
||||||
group: 'Classification'
|
group: 'Classification'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'managingStock',
|
||||||
|
label: 'Managing Stock',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ label: 'Yes', value: 'true' },
|
||||||
|
{ label: 'No', value: 'false' }
|
||||||
|
],
|
||||||
|
group: 'Classification'
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
interface ProductFiltersProps {
|
interface ProductFiltersProps {
|
||||||
@@ -98,9 +145,30 @@ export function ProductFilters({
|
|||||||
onClearFilters,
|
onClearFilters,
|
||||||
activeFilters,
|
activeFilters,
|
||||||
}: ProductFiltersProps) {
|
}: ProductFiltersProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [showCommand, setShowCommand] = React.useState(false);
|
||||||
const [selectedFilter, setSelectedFilter] = React.useState<FilterOption | null>(null);
|
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
|
// Update filter options with dynamic data
|
||||||
const filterOptions = React.useMemo(() => {
|
const filterOptions = React.useMemo(() => {
|
||||||
@@ -121,6 +189,56 @@ export function ProductFilters({
|
|||||||
});
|
});
|
||||||
}, [categories, vendors]);
|
}, [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(() => {
|
const activeFiltersList = React.useMemo(() => {
|
||||||
if (!activeFilters) return [];
|
if (!activeFilters) return [];
|
||||||
|
|
||||||
@@ -142,64 +260,23 @@ export function ProductFilters({
|
|||||||
});
|
});
|
||||||
}, [activeFilters, filterOptions]);
|
}, [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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 border-dashed"
|
className="h-8 border-dashed"
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => {
|
||||||
|
setShowCommand(true);
|
||||||
|
setSelectedFilter(null);
|
||||||
|
setInputValue("");
|
||||||
|
setSearchValue("");
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Filter
|
Add Filter
|
||||||
</Button>
|
</Button>
|
||||||
{activeFiltersList.length > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={onClearFilters}
|
|
||||||
>
|
|
||||||
Clear Filters
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeFiltersList.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{activeFiltersList.map((filter) => (
|
{activeFiltersList.map((filter) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
@@ -213,69 +290,199 @@ export function ProductFilters({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-4 w-4 p-0 hover:bg-transparent"
|
className="h-4 w-4 p-0 hover:bg-transparent"
|
||||||
onClick={() => handleRemoveFilter(filter.id)}
|
onClick={() => {
|
||||||
|
const newFilters = { ...activeFilters };
|
||||||
|
delete newFilters[filter.id];
|
||||||
|
onFilterChange(newFilters);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</Button>
|
</Button>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
{activeFiltersList.length > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8"
|
||||||
|
onClick={onClearFilters}
|
||||||
|
>
|
||||||
|
Clear All
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
{showCommand && (
|
||||||
<Command className="rounded-lg border shadow-md">
|
<Command
|
||||||
<DialogTitle className="sr-only">Search Filters</DialogTitle>
|
className="rounded-lg border"
|
||||||
<DialogDescription className="sr-only">
|
shouldFilter={false}
|
||||||
Search and select filters to apply to the product list
|
>
|
||||||
</DialogDescription>
|
{!selectedFilter ? (
|
||||||
<CommandInput placeholder="Search filters..." />
|
<>
|
||||||
|
<CommandInput
|
||||||
|
placeholder="Search and select filters..."
|
||||||
|
value={searchValue}
|
||||||
|
onValueChange={setSearchValue}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No filters found.</CommandEmpty>
|
<CommandEmpty>No filters found.</CommandEmpty>
|
||||||
{Object.entries(
|
{Object.entries(
|
||||||
filterOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
|
filteredOptions.reduce<Record<string, FilterOption[]>>((acc, filter) => {
|
||||||
if (!acc[filter.group]) acc[filter.group] = [];
|
if (!acc[filter.group]) acc[filter.group] = [];
|
||||||
acc[filter.group].push(filter);
|
acc[filter.group].push(filter);
|
||||||
return acc;
|
return acc;
|
||||||
}, {})
|
}, {})
|
||||||
).map(([group, filters]) => (
|
).map(([group, filters]) => (
|
||||||
<CommandGroup key={group} heading={group}>
|
<React.Fragment key={group}>
|
||||||
|
<CommandGroup heading={group}>
|
||||||
{filters.map((filter) => (
|
{filters.map((filter) => (
|
||||||
<CommandItem
|
<CommandItem
|
||||||
key={filter.id}
|
key={filter.id}
|
||||||
onSelect={() => handleSelectFilter(filter)}
|
value={`${filter.id} ${filter.label}`}
|
||||||
|
onSelect={() => {
|
||||||
|
handleSelectFilter(filter);
|
||||||
|
if (filter.type !== 'select') {
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"cursor-pointer",
|
||||||
|
activeFilters?.[filter.id] && "bg-accent"
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{filter.label}
|
{filter.label}
|
||||||
|
{activeFilters?.[filter.id] && (
|
||||||
|
<span className="ml-auto text-xs text-muted-foreground">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
|
<CommandSeparator />
|
||||||
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</>
|
||||||
</CommandDialog>
|
) : selectedFilter.type === 'select' ? (
|
||||||
|
<>
|
||||||
{selectedFilter?.type === 'select' && (
|
<CommandInput
|
||||||
<CommandDialog open={!!selectedFilter} onOpenChange={() => setSelectedFilter(null)}>
|
placeholder={`Select ${selectedFilter.label.toLowerCase()}...`}
|
||||||
<Command className="rounded-lg border shadow-md">
|
value={inputValue}
|
||||||
<DialogTitle className="sr-only">Select {selectedFilter.label}</DialogTitle>
|
onValueChange={setInputValue}
|
||||||
<DialogDescription className="sr-only">
|
onKeyDown={(e) => {
|
||||||
Choose a value for the {selectedFilter.label.toLowerCase()} filter
|
if (e.key === 'Backspace' && !inputValue) {
|
||||||
</DialogDescription>
|
e.preventDefault();
|
||||||
<CommandInput placeholder={`Select ${selectedFilter.label.toLowerCase()}...`} />
|
setSelectedFilter(null);
|
||||||
|
} else {
|
||||||
|
handleKeyDown(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<CommandList>
|
<CommandList>
|
||||||
<CommandEmpty>No options found.</CommandEmpty>
|
<CommandEmpty>No options found.</CommandEmpty>
|
||||||
<CommandGroup>
|
<CommandGroup>
|
||||||
{selectedFilter.options?.map((option) => (
|
<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
|
<CommandItem
|
||||||
key={option.value}
|
key={option.value}
|
||||||
onSelect={() => handleApplyFilter(option.value)}
|
value={option.value}
|
||||||
|
onSelect={() => {
|
||||||
|
handleApplyFilter(option.value);
|
||||||
|
setShowCommand(false);
|
||||||
|
}}
|
||||||
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</CommandItem>
|
</CommandItem>
|
||||||
))}
|
))
|
||||||
|
}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
</CommandList>
|
</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>
|
</Command>
|
||||||
</CommandDialog>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 { Button } from "@/components/ui/button";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import {
|
import {
|
||||||
@@ -10,6 +11,24 @@ import {
|
|||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} from "@/components/ui/table";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
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 {
|
interface Product {
|
||||||
product_id: string;
|
product_id: string;
|
||||||
@@ -62,10 +81,12 @@ interface Product {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: keyof Product;
|
key: keyof Product | 'image';
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
format?: (value: any) => string | number;
|
format?: (value: any) => string | number;
|
||||||
|
width?: string;
|
||||||
|
noLabel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductTableProps {
|
interface ProductTableProps {
|
||||||
@@ -75,6 +96,62 @@ interface ProductTableProps {
|
|||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
visibleColumns: Set<keyof Product>;
|
visibleColumns: Set<keyof Product>;
|
||||||
columnDefs: ColumnDef[];
|
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({
|
export function ProductTable({
|
||||||
@@ -84,7 +161,45 @@ export function ProductTable({
|
|||||||
sortDirection,
|
sortDirection,
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
columnDefs,
|
columnDefs,
|
||||||
|
onColumnOrderChange,
|
||||||
}: ProductTableProps) {
|
}: 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) => {
|
const getSortIcon = (column: keyof Product) => {
|
||||||
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
|
if (sortColumn !== column) return <ArrowUpDown className="ml-2 h-4 w-4" />;
|
||||||
return (
|
return (
|
||||||
@@ -140,19 +255,24 @@ export function ProductTable({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatColumnValue = (product: Product, column: ColumnDef) => {
|
const formatColumnValue = (product: Product, column: keyof Product | 'image') => {
|
||||||
const value = product[column.key];
|
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) {
|
||||||
switch (column.key) {
|
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':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-[300px]">
|
||||||
<Avatar className="h-8 w-8">
|
<div className="font-medium">{product.title}</div>
|
||||||
<AvatarImage src={product.image || undefined} alt={product.title} />
|
<div className="text-sm text-muted-foreground">{product.sku}</div>
|
||||||
<AvatarFallback>{product.title.charAt(0).toUpperCase()}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<span className="font-medium">{value as string}</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'categories':
|
case 'categories':
|
||||||
@@ -176,51 +296,55 @@ export function ProductTable({
|
|||||||
<Badge variant="outline">Hidden</Badge>
|
<Badge variant="outline">Hidden</Badge>
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
if (column.format && value !== undefined && value !== null) {
|
if (columnDef?.format && value !== undefined && value !== null) {
|
||||||
return column.format(value);
|
return columnDef.format(value);
|
||||||
}
|
}
|
||||||
return value || '-';
|
return value || '-';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get visible column definitions in order
|
|
||||||
const visibleColumnDefs = columnDefs.filter(col => visibleColumns.has(col.key));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
<Table>
|
<Table>
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
{visibleColumnDefs.map((column) => (
|
<SortableContext
|
||||||
<TableHead key={column.key}>
|
items={orderedColumns}
|
||||||
<Button
|
strategy={horizontalListSortingStrategy}
|
||||||
variant="ghost"
|
|
||||||
onClick={() => onSort(column.key)}
|
|
||||||
>
|
>
|
||||||
{column.label}
|
{orderedColumns.map((column) => (
|
||||||
{getSortIcon(column.key)}
|
<SortableHeader
|
||||||
</Button>
|
key={column}
|
||||||
</TableHead>
|
column={column}
|
||||||
|
columnDef={columnDefs.find(def => def.key === column)}
|
||||||
|
onSort={onSort}
|
||||||
|
sortColumn={sortColumn}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
|
</SortableContext>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
</DndContext>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product) => {
|
{products.map((product) => (
|
||||||
console.log('Rendering product:', product.product_id, product.title, product.categories);
|
|
||||||
return (
|
|
||||||
<TableRow key={product.product_id}>
|
<TableRow key={product.product_id}>
|
||||||
{visibleColumnDefs.map((column) => (
|
{orderedColumns.map((column) => (
|
||||||
<TableCell key={`${product.product_id}-${column.key}`}>
|
<TableCell key={`${product.product_id}-${column}`}>
|
||||||
{formatColumnValue(product, column)}
|
{formatColumnValue(product, column)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
{!products.length && (
|
{!products.length && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={visibleColumnDefs.length}
|
colSpan={orderedColumns.length}
|
||||||
className="text-center py-8 text-muted-foreground"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
No products found
|
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 { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { ProductFilters } from '@/components/products/ProductFilters';
|
import { ProductFilters } from '@/components/products/ProductFilters';
|
||||||
import { ProductTable } from '@/components/products/ProductTable';
|
import { ProductTable } from '@/components/products/ProductTable';
|
||||||
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
||||||
import debounce from 'lodash/debounce';
|
|
||||||
import {
|
import {
|
||||||
Pagination,
|
Pagination,
|
||||||
PaginationContent,
|
PaginationContent,
|
||||||
@@ -76,14 +75,6 @@ interface Product {
|
|||||||
lead_time_status?: string;
|
lead_time_status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductFiltersState {
|
|
||||||
search: string;
|
|
||||||
category: string;
|
|
||||||
vendor: string;
|
|
||||||
stockStatus: string;
|
|
||||||
minPrice: string;
|
|
||||||
maxPrice: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column definition interface
|
// Column definition interface
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
@@ -169,6 +160,7 @@ export function Products() {
|
|||||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product>>(new Set(DEFAULT_VISIBLE_COLUMNS));
|
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
|
// Group columns by their group property
|
||||||
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||||
@@ -283,6 +275,20 @@ export function Products() {
|
|||||||
setPage(newPage);
|
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 = () => {
|
const renderPagination = () => {
|
||||||
if (!data?.pagination.pages || data.pagination.pages <= 1) return null;
|
if (!data?.pagination.pages || data.pagination.pages <= 1) return null;
|
||||||
|
|
||||||
@@ -408,20 +414,18 @@ export function Products() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div ref={tableRef}>
|
||||||
<ProductFilters
|
<ProductFilters
|
||||||
filters={filters}
|
categories={data?.filters?.categories ?? []}
|
||||||
categories={data?.filters.categories ?? []}
|
vendors={data?.filters?.vendors ?? []}
|
||||||
vendors={data?.filters.vendors ?? []}
|
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
activeFilters={filters}
|
activeFilters={filters}
|
||||||
/>
|
/>
|
||||||
|
<div className="mt-6">
|
||||||
<div ref={tableRef}>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<ProductTableSkeleton />
|
<ProductTableSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<>
|
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{isFetching && (
|
{isFetching && (
|
||||||
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50">
|
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50">
|
||||||
@@ -430,17 +434,20 @@ export function Products() {
|
|||||||
)}
|
)}
|
||||||
<ProductTable
|
<ProductTable
|
||||||
products={data?.products ?? []}
|
products={data?.products ?? []}
|
||||||
onSort={handleSort}
|
visibleColumns={new Set(columnOrder)}
|
||||||
sortColumn={sortColumn}
|
sortColumn={sortColumn}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
columnDefs={AVAILABLE_COLUMNS}
|
columnDefs={AVAILABLE_COLUMNS}
|
||||||
|
onSort={handleSort}
|
||||||
|
onColumnOrderChange={handleColumnOrderChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{renderPagination()}
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
{renderPagination()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user