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(
'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
const query = `
@@ -300,7 +303,8 @@ router.get('/', async (req, res) => {
},
filters: {
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) {

View File

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

View File

@@ -262,15 +262,17 @@ export function ProductTable({
switch (column) {
case 'image':
return product.image ? (
<div className="flex items-center justify-center w-[60px]">
<img
src={product.image}
alt={product.title}
className="h-12 w-12 object-contain bg-white rounded border"
className="h-12 w-12 object-contain bg-white"
/>
</div>
) : null;
case 'title':
return (
<div className="min-w-[300px]">
<div className="min-w-[200px]">
<div className="font-medium">{product.title}</div>
<div className="text-sm text-muted-foreground">{product.sku}</div>
</div>
@@ -297,6 +299,13 @@ export function ProductTable({
);
default:
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 value || '-';

View File

@@ -1,58 +1,22 @@
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
export function ProductTableSkeleton() {
return (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Product</TableHead>
<TableHead>SKU</TableHead>
<TableHead>Stock</TableHead>
<TableHead>Price</TableHead>
<TableHead>Regular Price</TableHead>
<TableHead>Cost</TableHead>
<TableHead>Vendor</TableHead>
<TableHead>Brand</TableHead>
<TableHead>Categories</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<div className="divide-y">
{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 key={i} className="flex items-center p-4 gap-4">
<Skeleton className="h-12 w-12 rounded bg-muted" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-[40%] bg-muted" />
<Skeleton className="h-3 w-[25%] bg-muted" />
</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" />
<Skeleton className="h-4 w-[10%] bg-muted" />
<Skeleton className="h-4 w-[15%] bg-muted" />
<Skeleton className="h-4 w-[10%] bg-muted" />
</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
interface ColumnDef {
key: keyof Product;
key: keyof Product | 'image';
label: string;
group: string;
format?: (value: any) => string | number;
width?: string;
noLabel?: boolean;
}
// Define available columns with grouping
const AVAILABLE_COLUMNS: ColumnDef[] = [
// Image (special column)
{ key: 'image', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
// Basic Info Group
{ key: 'title', label: 'Title', 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' },
// 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: 'days_of_inventory', label: 'Days of Inventory', group: 'Inventory' },
{ key: 'reorder_point', label: 'Reorder Point', group: 'Inventory' },
{ key: 'safety_stock', label: 'Safety Stock', group: 'Inventory' },
{ key: 'moq', label: 'MOQ', group: 'Inventory' },
{ key: 'uom', label: 'UOM', 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', format: (v) => v?.toString() ?? '-' },
{ key: 'safety_stock', label: 'Safety Stock', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
{ key: 'moq', label: 'MOQ', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
{ key: 'uom', label: 'UOM', group: 'Inventory', format: (v) => v?.toString() ?? '-' },
// Pricing Group
{ 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: '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: 'price', label: '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: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
// Sales Metrics Group
{ 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: '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: '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: '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) ?? '-' },
// Purchase & Lead Time Group
{ key: 'avg_lead_time_days', label: 'Avg Lead Time (Days)', group: 'Purchase & Lead Time' },
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Purchase & Lead Time' },
{ key: 'target_lead_time', label: 'Target Lead Time', 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', format: (v) => v?.toFixed(1) ?? '-' },
{ 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: 'last_purchase_date', label: 'Last Purchase', 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
const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
const DEFAULT_VISIBLE_COLUMNS: (keyof Product | 'image')[] = [
'image',
'title',
'sku',
'stock_quantity',
@@ -154,13 +160,15 @@ const DEFAULT_VISIBLE_COLUMNS: (keyof Product)[] = [
export function Products() {
const queryClient = useQueryClient();
const tableRef = useRef<HTMLDivElement>(null);
const [filters, setFilters] = useState<Record<string, string | number | boolean>>({});
const [sortColumn, setSortColumn] = useState<keyof Product>('title');
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);
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product | 'image'>>(new Set(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
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
@@ -172,7 +180,7 @@ export function Products() {
}, {} as Record<string, ColumnDef[]>);
// Toggle column visibility
const toggleColumn = (columnKey: keyof Product) => {
const toggleColumn = (columnKey: keyof Product | 'image') => {
setVisibleColumns(prev => {
const next = new Set(prev);
if (next.has(columnKey)) {
@@ -244,13 +252,6 @@ export function Products() {
}
}, [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) => {
if (sortColumn === column) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
@@ -272,21 +273,18 @@ export function Products() {
};
const handlePageChange = (newPage: number) => {
window.scrollTo({ top: 0 });
setPage(newPage);
};
// Update column order when visibility changes
useEffect(() => {
// Handle column reordering from drag and drop
const handleColumnOrderChange = (newOrder: (keyof Product | 'image')[]) => {
setColumnOrder(prev => {
const newOrder = prev.filter(col => visibleColumns.has(col));
const newColumns = Array.from(visibleColumns).filter(col => !prev.includes(col));
return [...newOrder, ...newColumns];
// Keep hidden columns in their current positions
const newOrderSet = new Set(newOrder);
const hiddenColumns = prev.filter(col => !newOrderSet.has(col));
return [...newOrder, ...hiddenColumns];
});
}, [visibleColumns]);
// Handle column reordering
const handleColumnOrderChange = (newOrder: (keyof Product)[]) => {
setColumnOrder(newOrder);
};
const renderPagination = () => {
@@ -376,12 +374,24 @@ export function Products() {
return (
<div className="p-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Products</h1>
<div>
<div className="flex items-center justify-between mb-4">
<ProductFilters
categories={data?.filters?.categories ?? []}
vendors={data?.filters?.vendors ?? []}
brands={data?.filters?.brands ?? []}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
/>
<div className="flex items-center gap-4">
{data?.pagination.total && (
<div className="text-sm text-muted-foreground">
{data?.pagination.total.toLocaleString() ?? '...'} products
{data.pagination.total.toLocaleString()} products
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
@@ -389,7 +399,7 @@ export function Products() {
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[280px]">
<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]) => (
@@ -413,35 +423,19 @@ export function Products() {
</DropdownMenu>
</div>
</div>
<div ref={tableRef}>
<ProductFilters
categories={data?.filters?.categories ?? []}
vendors={data?.filters?.vendors ?? []}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
/>
<div className="mt-6">
{isLoading ? (
<div className="mt-4">
{isLoading || isFetching ? (
<ProductTableSkeleton />
) : (
<div className="relative">
{isFetching && (
<div className="absolute inset-0 bg-background/50 backdrop-blur-sm flex items-center justify-center z-50">
<div className="text-muted-foreground">Loading...</div>
</div>
)}
<ProductTable
products={data?.products ?? []}
visibleColumns={new Set(columnOrder)}
visibleColumns={visibleColumns}
sortColumn={sortColumn}
sortDirection={sortDirection}
columnDefs={AVAILABLE_COLUMNS}
onSort={handleSort}
onColumnOrderChange={handleColumnOrderChange}
/>
</div>
)}
</div>
</div>