Add product detail drawer

This commit is contained in:
2025-01-13 22:05:11 -05:00
parent 33a7a2a41c
commit dbd3f6b490
6 changed files with 539 additions and 33 deletions

View File

@@ -46,7 +46,8 @@
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tanstack": "^1.0.0"
"tanstack": "^1.0.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
@@ -6854,6 +6855,19 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vaul": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-dialog": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/victory-vendor": {
"version": "36.9.2",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz",

View File

@@ -48,7 +48,8 @@
"sonner": "^1.7.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"tanstack": "^1.0.0"
"tanstack": "^1.0.0",
"vaul": "^1.1.2"
},
"devDependencies": {
"@eslint/js": "^9.17.0",

View File

@@ -0,0 +1,352 @@
import { useQuery } from "@tanstack/react-query";
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from "@/components/ui/drawer";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { Card } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
import config from "@/config";
interface Product {
product_id: string;
title: string;
sku: string;
stock_quantity: number;
price: number;
regular_price: number;
cost_price: number;
vendor: string;
brand: string;
categories: string[];
// Metrics
daily_sales_avg: number;
weekly_sales_avg: number;
monthly_sales_avg: number;
days_of_inventory: number;
reorder_point: number;
safety_stock: number;
avg_margin_percent: number;
total_revenue: number;
inventory_value: number;
turnover_rate: number;
abc_class: string;
stock_status: string;
// Time series data
monthly_sales: Array<{
month: string;
quantity: number;
revenue: number;
}>;
recent_orders: Array<{
date: string;
order_number: string;
quantity: number;
price: number;
}>;
recent_purchases: Array<{
date: string;
po_id: string;
ordered: number;
received: number;
status: string;
}>;
}
interface ProductDetailProps {
productId: string | null;
onClose: () => void;
}
export function ProductDetail({ productId, onClose }: ProductDetailProps) {
const { data: product, isLoading } = useQuery<Product>({
queryKey: ["product", productId],
queryFn: async () => {
if (!productId) return null;
const response = await fetch(`${config.apiUrl}/products/${productId}`);
if (!response.ok) {
throw new Error("Failed to fetch product details");
}
return response.json();
},
enabled: !!productId,
});
if (!productId) return null;
return (
<Drawer open={!!productId} onOpenChange={(open) => !open && onClose()}>
<DrawerContent className="h-[85vh]">
<DrawerHeader>
<DrawerTitle>
{isLoading ? (
<Skeleton className="h-8 w-[200px]" />
) : (
product?.title
)}
</DrawerTitle>
<DrawerDescription>
{isLoading ? (
"\u00A0" // Non-breaking space for loading state
) : (
`SKU: ${product?.sku}`
)}
</DrawerDescription>
</DrawerHeader>
<div className="px-4">
<Tabs defaultValue="overview" className="w-full">
<TabsList className="w-full justify-start">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="inventory">Inventory</TabsTrigger>
<TabsTrigger value="sales">Sales</TabsTrigger>
<TabsTrigger value="purchase">Purchase History</TabsTrigger>
<TabsTrigger value="metrics">Performance Metrics</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
{isLoading ? (
<div className="space-y-4">
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
</div>
) : (
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Basic Information</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Brand</dt>
<dd>{product?.brand || "N/A"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Vendor</dt>
<dd>{product?.vendor || "N/A"}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Categories</dt>
<dd>{Array.isArray(product?.categories) ? product.categories.join(", ") : "N/A"}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Pricing</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Price</dt>
<dd>${typeof product?.price === 'number' ? product.price.toFixed(2) : 'N/A'}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Regular Price</dt>
<dd>${typeof product?.regular_price === 'number' ? product.regular_price.toFixed(2) : 'N/A'}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Cost Price</dt>
<dd>${typeof product?.cost_price === 'number' ? product.cost_price.toFixed(2) : 'N/A'}</dd>
</div>
</dl>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="inventory" className="space-y-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Current Stock</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Stock Quantity</dt>
<dd className="text-2xl font-semibold">{product?.stock_quantity}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Days of Inventory</dt>
<dd className="text-2xl font-semibold">{product?.days_of_inventory || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Status</dt>
<dd className="text-2xl font-semibold">{product?.stock_status || "N/A"}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Stock Thresholds</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Reorder Point</dt>
<dd>{product?.reorder_point || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Safety Stock</dt>
<dd>{product?.safety_stock || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">ABC Class</dt>
<dd>{product?.abc_class || "N/A"}</dd>
</div>
</dl>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="sales" className="space-y-4">
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Sales Metrics</h3>
<dl className="grid grid-cols-3 gap-4">
<div>
<dt className="text-sm text-muted-foreground">Daily Sales Avg</dt>
<dd>{product?.daily_sales_avg?.toFixed(2) || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Weekly Sales Avg</dt>
<dd>{product?.weekly_sales_avg?.toFixed(2) || 0}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Monthly Sales Avg</dt>
<dd>{product?.monthly_sales_avg?.toFixed(2) || 0}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Monthly Sales Trend</h3>
<div className="h-64">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={product?.monthly_sales || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="month" />
<YAxis yAxisId="left" />
<YAxis yAxisId="right" orientation="right" />
<Tooltip />
<Line yAxisId="left" type="monotone" dataKey="quantity" stroke="#8884d8" name="Quantity" />
<Line yAxisId="right" type="monotone" dataKey="revenue" stroke="#82ca9d" name="Revenue" />
</LineChart>
</ResponsiveContainer>
</div>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Recent Orders</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Order #</TableHead>
<TableHead>Quantity</TableHead>
<TableHead>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{product?.recent_orders?.map((order) => (
<TableRow key={order.order_number}>
<TableCell>{order.date}</TableCell>
<TableCell>{order.order_number}</TableCell>
<TableCell>{order.quantity}</TableCell>
<TableCell>${order.price.toFixed(2)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="purchase" className="space-y-4">
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : (
<div className="space-y-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Recent Purchase Orders</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>PO #</TableHead>
<TableHead>Ordered</TableHead>
<TableHead>Received</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{product?.recent_purchases?.map((po) => (
<TableRow key={po.po_id}>
<TableCell>{po.date}</TableCell>
<TableCell>{po.po_id}</TableCell>
<TableCell>{po.ordered}</TableCell>
<TableCell>{po.received}</TableCell>
<TableCell>{po.status}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
</div>
)}
</TabsContent>
<TabsContent value="metrics" className="space-y-4">
{isLoading ? (
<Skeleton className="h-48 w-full" />
) : (
<div className="grid grid-cols-2 gap-4">
<Card className="p-4">
<h3 className="font-semibold mb-2">Financial Metrics</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Total Revenue</dt>
<dd>${product?.total_revenue?.toFixed(2) || '0.00'}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Margin %</dt>
<dd>{product?.avg_margin_percent?.toFixed(2) || '0.00'}%</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Inventory Value</dt>
<dd>${product?.inventory_value?.toFixed(2) || '0.00'}</dd>
</div>
</dl>
</Card>
<Card className="p-4">
<h3 className="font-semibold mb-2">Performance Metrics</h3>
<dl className="space-y-2">
<div>
<dt className="text-sm text-muted-foreground">Turnover Rate</dt>
<dd>{product?.turnover_rate?.toFixed(2) || '0.00'}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">ABC Classification</dt>
<dd>Class {product?.abc_class || 'N/A'}</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">Stock Status</dt>
<dd>{product?.stock_status || 'N/A'}</dd>
</div>
</dl>
</Card>
</div>
)}
</TabsContent>
</Tabs>
</div>
</DrawerContent>
</Drawer>
);
}

View File

@@ -95,6 +95,7 @@ interface ProductTableProps {
columnDefs: ColumnDef[];
columnOrder: (keyof Product | 'image')[];
onColumnOrderChange?: (columns: (keyof Product | 'image')[]) => void;
onRowClick?: (product: Product) => void;
}
interface SortableHeaderProps {
@@ -159,8 +160,9 @@ export function ProductTable({
sortDirection,
visibleColumns,
columnDefs,
columnOrder,
columnOrder = columnDefs.map(col => col.key),
onColumnOrderChange,
onRowClick,
}: ProductTableProps) {
const [, setActiveId] = React.useState<keyof Product | null>(null);
const sensors = useSensors(
@@ -304,13 +306,13 @@ export function ProductTable({
};
return (
<div className="rounded-md border">
<Table>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<SortableContext
@@ -330,29 +332,33 @@ export function ProductTable({
</SortableContext>
</TableRow>
</TableHeader>
</DndContext>
<TableBody>
{products.map((product) => (
<TableRow key={product.product_id}>
{orderedColumns.map((column) => (
<TableCell key={`${product.product_id}-${column}`}>
{formatColumnValue(product, column)}
</TableCell>
))}
</TableRow>
))}
{!products.length && (
<TableRow>
<TableCell
colSpan={orderedColumns.length}
className="text-center py-8 text-muted-foreground"
<TableBody>
{products.map((product) => (
<TableRow
key={product.product_id}
onClick={() => onRowClick?.(product)}
className="cursor-pointer"
>
No products found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{orderedColumns.map((column) => (
<TableCell key={`${product.product_id}-${column}`}>
{formatColumnValue(product, column)}
</TableCell>
))}
</TableRow>
))}
{!products.length && (
<TableRow>
<TableCell
colSpan={orderedColumns.length}
className="text-center py-8 text-muted-foreground"
>
No products found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</DndContext>
);
}

View File

@@ -0,0 +1,125 @@
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
const Drawer = ({
shouldScaleBackground = true,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
<DrawerPrimitive.Root
shouldScaleBackground={shouldScaleBackground}
{...props}
/>
)
Drawer.displayName = "Drawer"
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
const DrawerOverlay = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
const DrawerContent = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content> & {
side?: "left" | "right" | "top" | "bottom"
}
>(({ className, children, side = "bottom", ...props }, ref) => (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 gap-4 bg-background",
{
"inset-x-0 bottom-0 mt-24 rounded-t-[10px] border-t": side === "bottom",
"inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-xl": side === "right",
"inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-xl": side === "left",
},
className
)}
{...props}
>
{side === "bottom" && (
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
)}
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
))
DrawerContent.displayName = "DrawerContent"
const DrawerHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("grid gap-1.5 p-4 text-center sm:text-left", className)}
{...props}
/>
)
DrawerHeader.displayName = "DrawerHeader"
const DrawerFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
DrawerFooter.displayName = "DrawerFooter"
const DrawerTitle = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
const DrawerDescription = React.forwardRef<
React.ElementRef<typeof DrawerPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
>(({ className, ...props }, ref) => (
<DrawerPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}

View File

@@ -3,6 +3,7 @@ import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-quer
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 {
Pagination,
PaginationContent,
@@ -169,6 +170,7 @@ export function Products() {
...DEFAULT_VISIBLE_COLUMNS,
...AVAILABLE_COLUMNS.map(col => col.key).filter(key => !DEFAULT_VISIBLE_COLUMNS.includes(key))
]);
const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
// Group columns by their group property
const columnsByGroup = AVAILABLE_COLUMNS.reduce((acc, col) => {
@@ -436,6 +438,7 @@ export function Products() {
columnOrder={columnOrder}
onSort={handleSort}
onColumnOrderChange={handleColumnOrderChange}
onRowClick={(product) => setSelectedProductId(product.product_id)}
/>
)}
</div>
@@ -443,6 +446,11 @@ export function Products() {
<div className="mt-4">
{renderPagination()}
</div>
<ProductDetail
productId={selectedProductId}
onClose={() => setSelectedProductId(null)}
/>
</div>
);
}