Add products filter views

This commit is contained in:
2025-01-15 12:05:23 -05:00
parent 6940b2fe7b
commit 12532d4f6f
3 changed files with 275 additions and 86 deletions

View File

@@ -0,0 +1,80 @@
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Product } from "@/types/products"
import { AlertCircle, AlertTriangle, CheckCircle2, PackageSearch, Star } from "lucide-react"
export type ProductView = {
id: string
label: string
icon: any
iconClassName: string
columns: (keyof Product)[]
}
export const PRODUCT_VIEWS: ProductView[] = [
{
id: "all",
label: "All Products",
icon: PackageSearch,
iconClassName: "text-muted-foreground",
columns: ["image", "title", "SKU", "stock_quantity", "price", "stock_status"]
},
{
id: "Critical",
label: "Critical Stock",
icon: AlertTriangle,
iconClassName: "text-destructive",
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "last_purchase_date", "lead_time_status"]
},
{
id: "Reorder",
label: "Reorder Soon",
icon: AlertCircle,
iconClassName: "text-warning",
columns: ["image", "title", "SKU", "stock_quantity", "daily_sales_avg", "last_purchase_date", "lead_time_status"]
},
{
id: "Healthy",
label: "Healthy Stock",
icon: CheckCircle2,
iconClassName: "text-success",
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
},
{
id: "Overstocked",
label: "Overstock",
icon: PackageSearch,
iconClassName: "text-muted-foreground",
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "last_sale_date", "abc_class"]
},
{
id: "New",
label: "New Products",
icon: Star,
iconClassName: "text-accent",
columns: ["image", "title", "stock_quantity", "daily_sales_avg", "stock_status", "abc_class"]
}
]
interface ProductViewsProps {
activeView: string
onViewChange: (view: string) => void
}
export function ProductViews({ activeView, onViewChange }: ProductViewsProps) {
return (
<Tabs value={activeView} onValueChange={onViewChange} className="w-full">
<TabsList className="inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground w-fit">
{PRODUCT_VIEWS.map((view) => (
<TabsTrigger
key={view.id}
value={view.id}
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow"
>
<view.icon className={`h-4 w-4 ${view.iconClassName} mr-2`} />
{view.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)
}

View File

@@ -1,9 +1,10 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useQuery, keepPreviousData } from '@tanstack/react-query'; import { useQuery, 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 { ProductDetail } from '@/components/products/ProductDetail'; import { ProductDetail } from '@/components/products/ProductDetail';
import { ProductViews } from '@/components/products/ProductViews';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { import {
DropdownMenu, DropdownMenu,
@@ -13,8 +14,17 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Settings2, ChevronsLeft, ChevronLeft, ChevronRight, ChevronsRight } from 'lucide-react'; import { Settings2 } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination"
// Enhanced Product interface with all possible fields // Enhanced Product interface with all possible fields
interface Product { interface Product {
@@ -133,13 +143,15 @@ export function Products() {
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 [currentPage, setCurrentPage] = useState(1);
const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product | 'image'>>(new Set(DEFAULT_VISIBLE_COLUMNS)); const [visibleColumns, setVisibleColumns] = useState<Set<keyof Product | 'image'>>(new Set(DEFAULT_VISIBLE_COLUMNS));
const [columnOrder, setColumnOrder] = useState<(keyof Product | 'image')[]>([ const [columnOrder, setColumnOrder] = useState<(keyof Product | 'image')[]>([
...DEFAULT_VISIBLE_COLUMNS, ...DEFAULT_VISIBLE_COLUMNS,
...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key)) ...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key))
]); ]);
const [selectedProductId, setSelectedProductId] = useState<number | null>(null); const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
const [activeView, setActiveView] = useState("all");
const [pageSize] = useState(50);
// 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) => {
@@ -160,23 +172,28 @@ export function Products() {
const params = new URLSearchParams(); const params = new URLSearchParams();
// Add pagination params // Add pagination params
params.append('page', page.toString()); params.append('page', currentPage.toString());
params.append('limit', '50'); params.append('limit', pageSize.toString());
// Add sorting params // Add sorting params
if (sortColumn) { if (sortColumn) {
params.append('sortColumn', sortColumn); params.append('sort', sortColumn);
params.append('sortDirection', sortDirection); params.append('order', sortDirection);
} }
// Add filter params // Add view filter
if (activeView !== 'all') {
params.append('stockStatus', activeView);
}
// Add other filters
Object.entries(filters).forEach(([key, value]) => { Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') { if (value !== undefined && value !== '') {
params.append(key, value.toString()); params.append(key, value.toString());
} }
}); });
const response = await fetch(`/api/products?${params.toString()}`); const response = await fetch('/api/products?' + params.toString());
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to fetch products'); throw new Error('Failed to fetch products');
} }
@@ -185,11 +202,18 @@ export function Products() {
// Query for products data // Query for products data
const { data, isFetching } = useQuery({ const { data, isFetching } = useQuery({
queryKey: ['products', page, sortColumn, sortDirection, filters], queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters],
queryFn: fetchProducts, queryFn: fetchProducts,
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
}); });
// Update current page if it exceeds the total pages
useEffect(() => {
if (data?.pagination.pages && currentPage > data.pagination.pages) {
setCurrentPage(1);
}
}, [currentPage, data?.pagination.pages]);
// Handle sort column change // Handle sort column change
const handleSort = (column: keyof Product) => { const handleSort = (column: keyof Product) => {
setSortDirection(prev => { setSortDirection(prev => {
@@ -202,74 +226,20 @@ export function Products() {
// Handle filter changes // Handle filter changes
const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => { const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => {
setFilters(newFilters); setFilters(newFilters);
setPage(1); setCurrentPage(1);
}; };
const handleClearFilters = () => { const handleClearFilters = () => {
setFilters({}); setFilters({});
setPage(1); setCurrentPage(1);
}; };
const handlePageChange = (newPage: number) => { // Function to handle page changes
setPage(newPage); const handlePageChange = (page: number) => {
setCurrentPage(page);
window.scrollTo({ top: 0, behavior: 'smooth' }); window.scrollTo({ top: 0, behavior: 'smooth' });
}; };
const renderPagination = () => {
if (!data) return null;
const { total, pages } = data.pagination;
if (total === 0) return null;
return (
<div className="flex items-center justify-between px-2">
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {page} of {pages}
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex items-center space-x-2">
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageChange(1)}
disabled={page === 1}
>
<span className="sr-only">Go to first page</span>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page - 1)}
disabled={page === 1}
>
<span className="sr-only">Go to previous page</span>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="h-8 w-8 p-0"
onClick={() => handlePageChange(page + 1)}
disabled={page === pages}
>
<span className="sr-only">Go to next page</span>
<ChevronRight className="h-4 w-4" />
</Button>
<Button
variant="outline"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => handlePageChange(pages)}
disabled={page === pages}
>
<span className="sr-only">Go to last page</span>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
};
const renderColumnToggle = () => ( const renderColumnToggle = () => (
<DropdownMenu modal={false}> <DropdownMenu modal={false}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -316,14 +286,32 @@ export function Products() {
</DropdownMenu> </DropdownMenu>
); );
// Calculate pagination numbers
const totalPages = data?.pagination.pages || 1;
const showEllipsis = totalPages > 7;
const pageNumbers = showEllipsis
? currentPage <= 4
? [1, 2, 3, 4, 5]
: currentPage >= totalPages - 3
? [totalPages - 4, totalPages - 3, totalPages - 2, totalPages - 1, totalPages]
: [currentPage - 2, currentPage - 1, currentPage, currentPage + 1, currentPage + 2]
: Array.from({ length: totalPages }, (_, i) => i + 1);
return ( return (
<motion.div <motion.div
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
transition={{ duration: 0.2 }} transition={{ duration: 0.2 }}
className="p-8 space-y-8" className="container mx-auto py-6 space-y-4"
> >
<h1 className="text-2xl font-bold">Products</h1> <div className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Products</h1>
</div>
<ProductViews activeView={activeView} onViewChange={(view) => {
setActiveView(view);
setCurrentPage(1);
}} />
<div> <div>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@@ -336,7 +324,7 @@ export function Products() {
activeFilters={filters} activeFilters={filters}
/> />
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{data?.pagination.total && ( {data?.pagination.total > 0 && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{data.pagination.total.toLocaleString()} products {data.pagination.total.toLocaleString()} products
</div> </div>
@@ -344,26 +332,105 @@ export function Products() {
{renderColumnToggle()} {renderColumnToggle()}
</div> </div>
</div> </div>
<div className="mt-4">
{isFetching ? ( {isFetching ? (
<ProductTableSkeleton /> <ProductTableSkeleton />
) : ( ) : (
<div className="space-y-4">
<ProductTable <ProductTable
products={data?.products ?? []} products={data?.products || []}
visibleColumns={visibleColumns} onSort={handleSort}
sortColumn={sortColumn} sortColumn={sortColumn}
sortDirection={sortDirection} sortDirection={sortDirection}
visibleColumns={visibleColumns}
columnDefs={AVAILABLE_COLUMNS} columnDefs={AVAILABLE_COLUMNS}
columnOrder={columnOrder} columnOrder={columnOrder}
onSort={handleSort} onColumnOrderChange={setColumnOrder}
onColumnOrderChange={handleColumnOrderChange}
onRowClick={(product) => setSelectedProductId(product.product_id)} onRowClick={(product) => setSelectedProductId(product.product_id)}
/> />
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
handlePageChange(Math.max(1, currentPage - 1));
}}
aria-disabled={currentPage === 1}
/>
</PaginationItem>
{showEllipsis && currentPage > 4 && (
<>
<PaginationItem>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
handlePageChange(1);
}}
>
1
</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
</>
)}
{pageNumbers.map((page) => (
<PaginationItem key={page}>
<PaginationLink
href="#"
isActive={currentPage === page}
onClick={(e) => {
e.preventDefault();
handlePageChange(page);
}}
>
{page}
</PaginationLink>
</PaginationItem>
))}
{showEllipsis && currentPage < totalPages - 3 && (
<>
<PaginationItem>
<PaginationEllipsis />
</PaginationItem>
<PaginationItem>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
handlePageChange(totalPages);
}}
>
{totalPages}
</PaginationLink>
</PaginationItem>
</>
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
handlePageChange(Math.min(totalPages, currentPage + 1));
}}
aria-disabled={currentPage === totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)} )}
</div> </div>
</div> )}
<div className="mt-4">
{renderPagination()}
</div> </div>
<ProductDetail <ProductDetail

View File

@@ -0,0 +1,42 @@
export interface Product {
product_id: number;
title: string;
SKU: string;
stock_quantity: number;
price: number;
regular_price: number;
cost_price: number;
landing_cost_price: number | null;
barcode: string;
vendor: string;
vendor_reference: string;
brand: string;
categories: string[];
tags: string[];
options: Record<string, any>;
image: string | null;
moq: number;
uom: number;
visible: boolean;
managing_stock: boolean;
replenishable: boolean;
created_at: string;
updated_at: string;
// Metrics
daily_sales_avg?: number;
weekly_sales_avg?: number;
monthly_sales_avg?: number;
avg_quantity_per_order?: number;
number_of_orders?: number;
first_sale_date?: string;
last_sale_date?: string;
last_purchase_date?: string;
days_of_stock?: number;
stock_status?: string;
abc_class?: string;
profit_margin?: number;
reorder_point?: number;
max_stock?: number;
lead_time_status?: string;
}