Restore images, more formatting and filter improvements

This commit is contained in:
2025-01-13 14:31:29 -05:00
parent 19d068191c
commit c57f69698b
5 changed files with 344 additions and 352 deletions

View File

@@ -218,6 +218,9 @@ router.get('/', async (req, res) => {
const [vendors] = await pool.query( const [vendors] = await pool.query(
'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor' 'SELECT DISTINCT vendor FROM products WHERE visible = true AND vendor IS NOT NULL AND vendor != "" ORDER BY vendor'
); );
const [brands] = await pool.query(
'SELECT DISTINCT brand FROM products WHERE visible = true AND brand IS NOT NULL AND brand != "" ORDER BY brand'
);
// Main query with all fields // Main query with all fields
const query = ` const query = `
@@ -300,7 +303,8 @@ router.get('/', async (req, res) => {
}, },
filters: { filters: {
categories: categories.map(category => category.name), categories: categories.map(category => category.name),
vendors: vendors.map(vendor => vendor.vendor) vendors: vendors.map(vendor => vendor.vendor),
brands: brands.map(brand => brand.brand)
} }
}); });
} catch (error) { } catch (error) {

View File

@@ -10,9 +10,13 @@ import {
CommandList, CommandList,
CommandSeparator, 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 { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
type FilterValue = string | number | boolean; type FilterValue = string | number | boolean;
@@ -133,6 +137,7 @@ const FILTER_OPTIONS: FilterOption[] = [
interface ProductFiltersProps { interface ProductFiltersProps {
categories: string[]; categories: string[];
vendors: string[]; vendors: string[];
brands: string[];
onFilterChange: (filters: Record<string, FilterValue>) => void; onFilterChange: (filters: Record<string, FilterValue>) => void;
onClearFilters: () => void; onClearFilters: () => void;
activeFilters: Record<string, FilterValue>; activeFilters: Record<string, FilterValue>;
@@ -141,6 +146,7 @@ interface ProductFiltersProps {
export function ProductFilters({ export function ProductFilters({
categories, categories,
vendors, vendors,
brands,
onFilterChange, onFilterChange,
onClearFilters, onClearFilters,
activeFilters, activeFilters,
@@ -150,9 +156,15 @@ export function ProductFilters({
const [inputValue, setInputValue] = React.useState(""); const [inputValue, setInputValue] = React.useState("");
const [searchValue, setSearchValue] = React.useState(""); const [searchValue, setSearchValue] = React.useState("");
// Handle escape key // Handle keyboard shortcuts
React.useEffect(() => { React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
// Command/Ctrl + K to toggle filter
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault();
setShowCommand(prev => !prev);
}
// Escape to close or go back
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (selectedFilter) { if (selectedFilter) {
setSelectedFilter(null); setSelectedFilter(null);
@@ -164,11 +176,9 @@ export function ProductFilters({
} }
}; };
if (showCommand) { window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); }, [selectedFilter]);
}
}, [showCommand, selectedFilter]);
// Update filter options with dynamic data // Update filter options with dynamic data
const filterOptions = React.useMemo(() => { const filterOptions = React.useMemo(() => {
@@ -185,9 +195,15 @@ export function ProductFilters({
options: vendors.map(vendor => ({ label: vendor, value: vendor })) options: vendors.map(vendor => ({ label: vendor, value: vendor }))
}; };
} }
if (option.id === 'brand') {
return {
...option,
options: brands.map(brand => ({ label: brand, value: brand }))
};
}
return option; return option;
}); });
}, [categories, vendors]); }, [categories, vendors, brands]);
// Filter options based on search // Filter options based on search
const filteredOptions = React.useMemo(() => { const filteredOptions = React.useMemo(() => {
@@ -263,20 +279,198 @@ export function ProductFilters({
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Popover open={showCommand} onOpenChange={setShowCommand}>
variant="outline" <PopoverTrigger asChild>
size="sm" <Button
className="h-8 border-dashed" variant="outline"
onClick={() => { size="sm"
setShowCommand(true); className="h-8 border-dashed"
setSelectedFilter(null); >
setInputValue(""); <Plus
setSearchValue(""); className={cn(
}} "mr-2 h-4 w-4 transition-transform duration-200",
> showCommand && "rotate-[135deg]"
<Plus className="mr-2 h-4 w-4" /> )}
Add Filter />
</Button> {showCommand ? "Cancel" : "Add Filter"}
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0 w-[520px]"
align="start"
>
<Command
className="rounded-none border-0"
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>
))}
</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>
</PopoverContent>
</Popover>
{activeFiltersList.map((filter) => ( {activeFiltersList.map((filter) => (
<Badge <Badge
key={filter.id} key={filter.id}
@@ -311,179 +505,6 @@ export function ProductFilters({
</Button> </Button>
)} )}
</div> </div>
{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>
))}
</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>
)}
</div> </div>
); );
} }

View File

@@ -262,15 +262,17 @@ export function ProductTable({
switch (column) { switch (column) {
case 'image': case 'image':
return product.image ? ( return product.image ? (
<img <div className="flex items-center justify-center w-[60px]">
src={product.image} <img
alt={product.title} src={product.image}
className="h-12 w-12 object-contain bg-white rounded border" alt={product.title}
/> className="h-12 w-12 object-contain bg-white"
/>
</div>
) : null; ) : null;
case 'title': case 'title':
return ( return (
<div className="min-w-[300px]"> <div className="min-w-[200px]">
<div className="font-medium">{product.title}</div> <div className="font-medium">{product.title}</div>
<div className="text-sm text-muted-foreground">{product.sku}</div> <div className="text-sm text-muted-foreground">{product.sku}</div>
</div> </div>
@@ -297,6 +299,13 @@ export function ProductTable({
); );
default: default:
if (columnDef?.format && value !== undefined && value !== null) { if (columnDef?.format && value !== undefined && value !== null) {
// For numeric formats (those using toFixed), ensure the value is a number
if (typeof value === 'string') {
const num = parseFloat(value);
if (!isNaN(num)) {
return columnDef.format(num);
}
}
return columnDef.format(value); return columnDef.format(value);
} }
return value || '-'; return value || '-';

View File

@@ -1,58 +1,22 @@
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function ProductTableSkeleton() { export function ProductTableSkeleton() {
return ( return (
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <div className="divide-y">
<TableHeader> {Array.from({ length: 20 }).map((_, i) => (
<TableRow> <div key={i} className="flex items-center p-4 gap-4">
<TableHead>Product</TableHead> <Skeleton className="h-12 w-12 rounded bg-muted" />
<TableHead>SKU</TableHead> <div className="flex-1 space-y-2">
<TableHead>Stock</TableHead> <Skeleton className="h-4 w-[40%] bg-muted" />
<TableHead>Price</TableHead> <Skeleton className="h-3 w-[25%] bg-muted" />
<TableHead>Regular Price</TableHead> </div>
<TableHead>Cost</TableHead> <Skeleton className="h-4 w-[10%] bg-muted" />
<TableHead>Vendor</TableHead> <Skeleton className="h-4 w-[15%] bg-muted" />
<TableHead>Brand</TableHead> <Skeleton className="h-4 w-[10%] bg-muted" />
<TableHead>Categories</TableHead> </div>
<TableHead>Status</TableHead> ))}
</TableRow> </div>
</TableHeader>
<TableBody>
{Array.from({ length: 20 }).map((_, i) => (
<TableRow key={i}>
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-4 w-[200px]" />
</div>
</TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Skeleton className="h-4 w-8" />
<Skeleton className="h-5 w-20 rounded-full" />
</div>
</TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-32" /></TableCell>
<TableCell><Skeleton className="h-5 w-16 rounded-full" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div> </div>
); );
} }

View File

@@ -78,14 +78,19 @@ interface Product {
// Column definition interface // Column definition interface
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;
} }
// Define available columns with grouping // Define available columns with grouping
const AVAILABLE_COLUMNS: ColumnDef[] = [ const AVAILABLE_COLUMNS: ColumnDef[] = [
// Image (special column)
{ key: 'image', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
// Basic Info Group // Basic Info Group
{ key: 'title', label: 'Title', group: 'Basic Info' }, { key: 'title', label: 'Title', group: 'Basic Info' },
{ key: 'sku', label: 'SKU', group: 'Basic Info' }, { key: 'sku', label: 'SKU', group: 'Basic Info' },
@@ -96,26 +101,26 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'barcode', label: 'Barcode', group: 'Basic Info' }, { key: 'barcode', label: 'Barcode', group: 'Basic Info' },
// Inventory Group // Inventory Group
{ key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory' }, { key: 'stock_quantity', label: 'Stock Quantity', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
{ key: 'stock_status', label: 'Stock Status', group: 'Inventory' }, { key: 'stock_status', label: 'Stock Status', group: 'Inventory' },
{ key: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory' }, { key: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'reorder_point', label: 'Reorder Point', group: 'Inventory' }, { key: 'reorder_point', label: 'Reorder Point', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
{ key: 'safety_stock', label: 'Safety Stock', group: 'Inventory' }, { key: 'safety_stock', label: 'Safety Stock', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
{ key: 'moq', label: 'MOQ', group: 'Inventory' }, { key: 'moq', label: 'MOQ', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
{ key: 'uom', label: 'UOM', group: 'Inventory' }, { key: 'uom', label: 'UOM', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
// Pricing Group // Pricing Group
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v.toFixed(2) }, { key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'regular_price', label: 'Regular Price', group: 'Pricing', format: (v) => v.toFixed(2) }, { key: 'regular_price', label: 'Regular Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cost_price', label: 'Cost Price', group: 'Pricing', format: (v) => v.toFixed(2) }, { key: 'cost_price', label: 'Cost Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v.toFixed(2) }, { key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
// Sales Metrics Group // Sales Metrics Group
{ key: 'daily_sales_avg', label: 'Daily Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'daily_sales_avg', label: 'Daily Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'weekly_sales_avg', label: 'Weekly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'weekly_sales_avg', label: 'Weekly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'monthly_sales_avg', label: 'Monthly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'monthly_sales_avg', label: 'Monthly Sales Avg', group: 'Sales Metrics', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'avg_quantity_per_order', label: 'Avg Qty per Order', group: 'Sales Metrics', format: (v) => v?.toFixed(1) ?? '-' }, { key: 'avg_quantity_per_order', label: 'Avg Qty per Order', group: 'Sales Metrics', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'number_of_orders', label: 'Number of Orders', group: 'Sales Metrics' }, { key: 'number_of_orders', label: 'Number of Orders', group: 'Sales Metrics', format: (v) => v?.toString() ?? '-' },
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales Metrics' }, { key: 'first_sale_date', label: 'First Sale', group: 'Sales Metrics' },
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales Metrics' }, { key: 'last_sale_date', label: 'Last Sale', group: 'Sales Metrics' },
@@ -129,9 +134,9 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' }, { key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial Metrics', format: (v) => v?.toFixed(2) ?? '-' },
// Purchase & Lead Time Group // Purchase & Lead Time Group
{ key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time' }, { key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time' }, { key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Purchase & Lead Time' }, { key: 'target_lead_time', label: 'Target Lead Time', group: 'Purchase & Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Purchase & Lead Time' }, { key: 'lead_time_status', label: 'Lead Time Status', group: 'Purchase & Lead Time' },
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Purchase & Lead Time' }, { key: 'last_purchase_date', label: 'Last Purchase', group: 'Purchase & Lead Time' },
{ key: 'last_received_date', label: 'Last Received', group: 'Purchase & Lead Time' }, { key: 'last_received_date', label: 'Last Received', group: 'Purchase & Lead Time' },
@@ -141,7 +146,8 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
]; ];
// Default visible columns // Default visible columns
const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [ const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
'image',
'title', 'title',
'sku', 'sku',
'stock_quantity', 'stock_quantity',
@@ -154,13 +160,15 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
export function Products() { export function Products() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const tableRef = useRef<HTMLDivElement>(null);
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({}); const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
const [sortColumn, setSortColumn] = useState<keyof Product>('title'); const [sortColumn, setSortColumn] = useState<keyof Product>('title');
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 | 'image'>>(new Set(DEFAULT_VISIBLE_COLUMNS));
const [columnOrder, setColumnOrder] = useState<(keyof Product)[]>(DEFAULT_VISIBLE_COLUMNS); const [columnOrder, setColumnOrder] = useState<(keyof Product | 'image')[]>([
...DEFAULT_VISIBLE_COLUMNS,
...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key))
]);
// 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) => {
@@ -172,7 +180,7 @@ export function Products() {
}, {} as Record<string, ColumnDef[]>); }, {} as Record<string, ColumnDef[]>);
// Toggle column visibility // Toggle column visibility
const toggleColumn = (columnKey: keyof Product) => { const toggleColumn = (columnKey: keyof Product | 'image') => {
setVisibleColumns(prev => { setVisibleColumns(prev => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(columnKey)) { if (next.has(columnKey)) {
@@ -244,13 +252,6 @@ export function Products() {
} }
}, [page, data?.pagination, queryClient, filters, sortColumn, sortDirection]); }, [page, data?.pagination, queryClient, filters, sortColumn, sortDirection]);
// Scroll to top when changing pages
useEffect(() => {
if (tableRef.current) {
tableRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, [page]);
const handleSort = (column: keyof Product) => { const handleSort = (column: keyof Product) => {
if (sortColumn === column) { if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
@@ -272,21 +273,18 @@ export function Products() {
}; };
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
window.scrollTo({ top: 0 });
setPage(newPage); setPage(newPage);
}; };
// Update column order when visibility changes // Handle column reordering from drag and drop
useEffect(() => { const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => {
setColumnOrder(prev => { setColumnOrder(prev => {
const newOrder = prev.filter(col => visibleColumns.has(col)); // Keep hidden columns in their current positions
const newColumns = Array.from(visibleColumns).filter(col => !prev.includes(col)); const newOrderSet = new Set(newOrder);
return [...newOrder, ...newColumns]; const hiddenColumns = prev.filter(col => !newOrderSet.has(col));
return [...newOrder, ...hiddenColumns];
}); });
}, [visibleColumns]);
// Handle column reordering
const handleColumnOrderChange = (newOrder: (keyof Product)[]) => {
setColumnOrder(newOrder);
}; };
const renderPagination = () => { const renderPagination = () => {
@@ -376,72 +374,68 @@ export function Products() {
return ( return (
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
<div className="flex items-center justify-between"> <h1 className="text-2xl font-bold">Products</h1>
<h1 className="text-2xl font-bold">Products</h1>
<div className="flex items-center gap-4">
<div className="text-sm text-muted-foreground">
{data?.pagination.total.toLocaleString() ?? '...'} products
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Settings2 className="mr-2 h-4 w-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
<DropdownMenuLabel>Toggle Columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(columnsByGroup).map(([group, columns]) => (
<div key={group}>
<DropdownMenuLabel className="text-xs font-bold text-muted-foreground">
{group}
</DropdownMenuLabel>
{columns.map((col) => (
<DropdownMenuCheckboxItem
key={col.key}
checked={visibleColumns.has(col.key)}
onCheckedChange={() => toggleColumn(col.key)}
>
{col.label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div ref={tableRef}> <div>
<ProductFilters <div className="flex items-center justify-between mb-4">
categories={data?.filters?.categories ?? []} <ProductFilters
vendors={data?.filters?.vendors ?? []} categories={data?.filters?.categories ?? []}
onFilterChange={handleFilterChange} vendors={data?.filters?.vendors ?? []}
onClearFilters={handleClearFilters} brands={data?.filters?.brands ?? []}
activeFilters={filters} onFilterChange={handleFilterChange}
/> onClearFilters={handleClearFilters}
<div className="mt-6"> activeFilters={filters}
{isLoading ? ( />
<div className="flex items-center gap-4">
{data?.pagination.total && (
<div className="text-sm text-muted-foreground">
{data.pagination.total.toLocaleString()} products
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Settings2 className="mr-2 h-4 w-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px] max-h-[calc(100vh-4rem)] overflow-y-auto">
<DropdownMenuLabel>Toggle Columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{Object.entries(columnsByGroup).map(([group, columns]) => (
<div key={group}>
<DropdownMenuLabel className="text-xs font-bold text-muted-foreground">
{group}
</DropdownMenuLabel>
{columns.map((col) => (
<DropdownMenuCheckboxItem
key={col.key}
checked={visibleColumns.has(col.key)}
onCheckedChange={() => toggleColumn(col.key)}
>
{col.label}
</DropdownMenuCheckboxItem>
))}
<DropdownMenuSeparator />
</div>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<div className="mt-4">
{isLoading || isFetching ? (
<ProductTableSkeleton /> <ProductTableSkeleton />
) : ( ) : (
<div className="relative"> <ProductTable
{isFetching && ( products={data?.products ?? []}
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50"> visibleColumns={visibleColumns}
<div className="text-muted-foreground">Loading...</div> sortColumn={sortColumn}
</div> sortDirection={sortDirection}
)} columnDefs={AVAILABLE_COLUMNS}
<ProductTable onSort={handleSort}
products={data?.products ?? []} onColumnOrderChange={handleColumnOrderChange}
visibleColumns={new Set(columnOrder)} />
sortColumn={sortColumn}
sortDirection={sortDirection}
columnDefs={AVAILABLE_COLUMNS}
onSort={handleSort}
onColumnOrderChange={handleColumnOrderChange}
/>
</div>
)} )}
</div> </div>
</div> </div>