diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 177e75a..6a2303f 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -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", diff --git a/inventory/package.json b/inventory/package.json index cb67f5b..d8eb2cd 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -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", diff --git a/inventory/src/components/products/ProductDetail.tsx b/inventory/src/components/products/ProductDetail.tsx new file mode 100644 index 0000000..87b4c84 --- /dev/null +++ b/inventory/src/components/products/ProductDetail.tsx @@ -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({ + 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 ( + !open && onClose()}> + + + + {isLoading ? ( + + ) : ( + product?.title + )} + + + {isLoading ? ( + "\u00A0" // Non-breaking space for loading state + ) : ( + `SKU: ${product?.sku}` + )} + + + +
+ + + Overview + Inventory + Sales + Purchase History + Performance Metrics + + + + {isLoading ? ( +
+ + +
+ ) : ( +
+ +

Basic Information

+
+
+
Brand
+
{product?.brand || "N/A"}
+
+
+
Vendor
+
{product?.vendor || "N/A"}
+
+
+
Categories
+
{Array.isArray(product?.categories) ? product.categories.join(", ") : "N/A"}
+
+
+
+ + +

Pricing

+
+
+
Price
+
${typeof product?.price === 'number' ? product.price.toFixed(2) : 'N/A'}
+
+
+
Regular Price
+
${typeof product?.regular_price === 'number' ? product.regular_price.toFixed(2) : 'N/A'}
+
+
+
Cost Price
+
${typeof product?.cost_price === 'number' ? product.cost_price.toFixed(2) : 'N/A'}
+
+
+
+
+ )} +
+ + + {isLoading ? ( + + ) : ( +
+ +

Current Stock

+
+
+
Stock Quantity
+
{product?.stock_quantity}
+
+
+
Days of Inventory
+
{product?.days_of_inventory || 0}
+
+
+
Status
+
{product?.stock_status || "N/A"}
+
+
+
+ + +

Stock Thresholds

+
+
+
Reorder Point
+
{product?.reorder_point || 0}
+
+
+
Safety Stock
+
{product?.safety_stock || 0}
+
+
+
ABC Class
+
{product?.abc_class || "N/A"}
+
+
+
+
+ )} +
+ + + {isLoading ? ( + + ) : ( +
+ +

Sales Metrics

+
+
+
Daily Sales Avg
+
{product?.daily_sales_avg?.toFixed(2) || 0}
+
+
+
Weekly Sales Avg
+
{product?.weekly_sales_avg?.toFixed(2) || 0}
+
+
+
Monthly Sales Avg
+
{product?.monthly_sales_avg?.toFixed(2) || 0}
+
+
+
+ + +

Monthly Sales Trend

+
+ + + + + + + + + + + +
+
+ + +

Recent Orders

+ + + + Date + Order # + Quantity + Price + + + + {product?.recent_orders?.map((order) => ( + + {order.date} + {order.order_number} + {order.quantity} + ${order.price.toFixed(2)} + + ))} + +
+
+
+ )} +
+ + + {isLoading ? ( + + ) : ( +
+ +

Recent Purchase Orders

+ + + + Date + PO # + Ordered + Received + Status + + + + {product?.recent_purchases?.map((po) => ( + + {po.date} + {po.po_id} + {po.ordered} + {po.received} + {po.status} + + ))} + +
+
+
+ )} +
+ + + {isLoading ? ( + + ) : ( +
+ +

Financial Metrics

+
+
+
Total Revenue
+
${product?.total_revenue?.toFixed(2) || '0.00'}
+
+
+
Margin %
+
{product?.avg_margin_percent?.toFixed(2) || '0.00'}%
+
+
+
Inventory Value
+
${product?.inventory_value?.toFixed(2) || '0.00'}
+
+
+
+ + +

Performance Metrics

+
+
+
Turnover Rate
+
{product?.turnover_rate?.toFixed(2) || '0.00'}
+
+
+
ABC Classification
+
Class {product?.abc_class || 'N/A'}
+
+
+
Stock Status
+
{product?.stock_status || 'N/A'}
+
+
+
+
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/products/ProductTable.tsx b/inventory/src/components/products/ProductTable.tsx index 12f554f..5312151 100644 --- a/inventory/src/components/products/ProductTable.tsx +++ b/inventory/src/components/products/ProductTable.tsx @@ -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(null); const sensors = useSensors( @@ -304,13 +306,13 @@ export function ProductTable({ }; return ( -
- - + +
+
- - - {products.map((product) => ( - - {orderedColumns.map((column) => ( - - {formatColumnValue(product, column)} - - ))} - - ))} - {!products.length && ( - - + {products.map((product) => ( + onRowClick?.(product)} + className="cursor-pointer" > - No products found - - - )} - -
-
+ {orderedColumns.map((column) => ( + + {formatColumnValue(product, column)} + + ))} + + ))} + {!products.length && ( + + + No products found + + + )} + + + + ); } \ No newline at end of file diff --git a/inventory/src/components/ui/drawer.tsx b/inventory/src/components/ui/drawer.tsx new file mode 100644 index 0000000..9e48d82 --- /dev/null +++ b/inventory/src/components/ui/drawer.tsx @@ -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) => ( + +) +Drawer.displayName = "Drawer" + +const DrawerTrigger = DrawerPrimitive.Trigger + +const DrawerPortal = DrawerPrimitive.Portal + +const DrawerClose = DrawerPrimitive.Close + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + side?: "left" | "right" | "top" | "bottom" + } +>(({ className, children, side = "bottom", ...props }, ref) => ( + + + + {side === "bottom" && ( +
+ )} + {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = DrawerPrimitive.Title.displayName + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = DrawerPrimitive.Description.displayName + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/inventory/src/pages/Products.tsx b/inventory/src/pages/Products.tsx index a285bb5..8bf9159 100644 --- a/inventory/src/pages/Products.tsx +++ b/inventory/src/pages/Products.tsx @@ -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(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)} /> )}
@@ -443,6 +446,11 @@ export function Products() {
{renderPagination()}
+ + setSelectedProductId(null)} + />
); } \ No newline at end of file