Add products filter views
This commit is contained in:
80
inventory/src/components/products/ProductViews.tsx
Normal file
80
inventory/src/components/products/ProductViews.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, keepPreviousData } from '@tanstack/react-query';
|
||||
import { ProductFilters } from '@/components/products/ProductFilters';
|
||||
import { ProductTable } from '@/components/products/ProductTable';
|
||||
import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton';
|
||||
import { ProductDetail } from '@/components/products/ProductDetail';
|
||||
import { ProductViews } from '@/components/products/ProductViews';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -13,8 +14,17 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} 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 {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from "@/components/ui/pagination"
|
||||
|
||||
// Enhanced Product interface with all possible fields
|
||||
interface Product {
|
||||
@@ -133,13 +143,15 @@ export function Products() {
|
||||
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 [currentPage, setCurrentPage] = useState(1);
|
||||
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))
|
||||
]);
|
||||
const [selectedProductId, setSelectedProductId] = useState<number | null>(null);
|
||||
const [activeView, setActiveView] = useState("all");
|
||||
const [pageSize] = useState(50);
|
||||
|
||||
// Group columns by their group property
|
||||
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
|
||||
@@ -160,23 +172,28 @@ export function Products() {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// Add pagination params
|
||||
params.append('page', page.toString());
|
||||
params.append('limit', '50');
|
||||
params.append('page', currentPage.toString());
|
||||
params.append('limit', pageSize.toString());
|
||||
|
||||
// Add sorting params
|
||||
if (sortColumn) {
|
||||
params.append('sortColumn', sortColumn);
|
||||
params.append('sortDirection', sortDirection);
|
||||
params.append('sort', sortColumn);
|
||||
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]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (value !== undefined && value !== '') {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/products?${params.toString()}`);
|
||||
const response = await fetch('/api/products?' + params.toString());
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch products');
|
||||
}
|
||||
@@ -185,11 +202,18 @@ export function Products() {
|
||||
|
||||
// Query for products data
|
||||
const { data, isFetching } = useQuery({
|
||||
queryKey: ['products', page, sortColumn, sortDirection, filters],
|
||||
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters],
|
||||
queryFn: fetchProducts,
|
||||
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
|
||||
const handleSort = (column: keyof Product) => {
|
||||
setSortDirection(prev => {
|
||||
@@ -202,74 +226,20 @@ export function Products() {
|
||||
// Handle filter changes
|
||||
const handleFilterChange = (newFilters: Record<string, string | number | boolean>) => {
|
||||
setFilters(newFilters);
|
||||
setPage(1);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleClearFilters = () => {
|
||||
setFilters({});
|
||||
setPage(1);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage);
|
||||
// Function to handle page changes
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
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 = () => (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
@@ -316,14 +286,32 @@ export function Products() {
|
||||
</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 (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
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 className="flex items-center justify-between mb-4">
|
||||
@@ -336,7 +324,7 @@ export function Products() {
|
||||
activeFilters={filters}
|
||||
/>
|
||||
<div className="flex items-center gap-4">
|
||||
{data?.pagination.total && (
|
||||
{data?.pagination.total > 0 && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.pagination.total.toLocaleString()} products
|
||||
</div>
|
||||
@@ -344,26 +332,105 @@ export function Products() {
|
||||
{renderColumnToggle()}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{isFetching ? (
|
||||
<ProductTableSkeleton />
|
||||
) : (
|
||||
|
||||
{isFetching ? (
|
||||
<ProductTableSkeleton />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<ProductTable
|
||||
products={data?.products ?? []}
|
||||
visibleColumns={visibleColumns}
|
||||
products={data?.products || []}
|
||||
onSort={handleSort}
|
||||
sortColumn={sortColumn}
|
||||
sortDirection={sortDirection}
|
||||
visibleColumns={visibleColumns}
|
||||
columnDefs={AVAILABLE_COLUMNS}
|
||||
columnOrder={columnOrder}
|
||||
onSort={handleSort}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnOrderChange={setColumnOrder}
|
||||
onRowClick={(product) => setSelectedProductId(product.product_id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{renderPagination()}
|
||||
|
||||
{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>
|
||||
|
||||
<ProductDetail
|
||||
|
||||
42
inventory/src/types/products.ts
Normal file
42
inventory/src/types/products.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user