diff --git a/inventory-server/src/routes/brandsAggregate.js b/inventory-server/src/routes/brandsAggregate.js index 5051fd3..de63100 100644 --- a/inventory-server/src/routes/brandsAggregate.js +++ b/inventory-server/src/routes/brandsAggregate.js @@ -192,7 +192,28 @@ router.get('/', async (req, res) => { ]); const total = parseInt(countResult.rows[0].total, 10); - const brands = dataResult.rows; + const brands = dataResult.rows.map(row => { + // Create a new object with both snake_case and camelCase keys + const transformedRow = { ...row }; // Start with original data + + for (const key in row) { + // Skip null/undefined values + if (row[key] === null || row[key] === undefined) { + continue; // Original already has the null value + } + + // Transform keys to match frontend expectations (add camelCase versions) + // First handle cases like sales_7d -> sales7d + let camelKey = key.replace(/_(\d+[a-z])/g, '$1'); + + // Then handle regular snake_case -> camelCase + camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + if (camelKey !== key) { // Only add if different from original + transformedRow[camelKey] = row[key]; + } + } + return transformedRow; + }); // --- Respond --- res.json({ diff --git a/inventory-server/src/routes/categoriesAggregate.js b/inventory-server/src/routes/categoriesAggregate.js index adaef2b..d9194d3 100644 --- a/inventory-server/src/routes/categoriesAggregate.js +++ b/inventory-server/src/routes/categoriesAggregate.js @@ -222,17 +222,20 @@ router.get('/', async (req, res) => { const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : ''; // Need JOIN for parent_name if sorting/filtering by it, or always include for display - const needParentJoin = sortColumn === 'p.name' || conditions.some(c => c.includes('p.name')); + const sortColumn = sortColumnInfo?.dbCol; + + // Always include the parent join for consistency + const parentJoinSql = 'LEFT JOIN public.categories p ON cm.parent_id = p.cat_id'; const baseSql = ` FROM public.category_metrics cm - ${needParentJoin ? 'LEFT JOIN public.categories p ON cm.parent_id = p.cat_id' : ''} + ${parentJoinSql} ${whereClause} `; const countSql = `SELECT COUNT(*) AS total ${baseSql}`; const dataSql = ` - SELECT cm.* ${needParentJoin ? ', p.name as parent_name' : ''} + SELECT cm.*, p.name as parent_name ${baseSql} ${sortClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1} @@ -248,7 +251,28 @@ router.get('/', async (req, res) => { ]); const total = parseInt(countResult.rows[0].total, 10); - const categories = dataResult.rows; + const categories = dataResult.rows.map(row => { + // Create a new object with both snake_case and camelCase keys + const transformedRow = { ...row }; // Start with original data + + for (const key in row) { + // Skip null/undefined values + if (row[key] === null || row[key] === undefined) { + continue; // Original already has the null value + } + + // Transform keys to match frontend expectations (add camelCase versions) + // First handle cases like sales_7d -> sales7d + let camelKey = key.replace(/_(\d+[a-z])/g, '$1'); + + // Then handle regular snake_case -> camelCase + camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + if (camelKey !== key) { // Only add if different from original + transformedRow[camelKey] = row[key]; + } + } + return transformedRow; + }); // --- Respond --- res.json({ diff --git a/inventory-server/src/routes/vendorsAggregate.js b/inventory-server/src/routes/vendorsAggregate.js index dda2565..9aba2c9 100644 --- a/inventory-server/src/routes/vendorsAggregate.js +++ b/inventory-server/src/routes/vendorsAggregate.js @@ -196,7 +196,28 @@ router.get('/', async (req, res) => { ]); const total = parseInt(countResult.rows[0].total, 10); - const vendors = dataResult.rows; + const vendors = dataResult.rows.map(row => { + // Create a new object with both snake_case and camelCase keys + const transformedRow = { ...row }; // Start with original data + + for (const key in row) { + // Skip null/undefined values + if (row[key] === null || row[key] === undefined) { + continue; // Original already has the null value + } + + // Transform keys to match frontend expectations (add camelCase versions) + // First handle cases like sales_7d -> sales7d + let camelKey = key.replace(/_(\d+[a-z])/g, '$1'); + + // Then handle regular snake_case -> camelCase + camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()); + if (camelKey !== key) { // Only add if different from original + transformedRow[camelKey] = row[key]; + } + } + return transformedRow; + }); // --- Respond --- res.json({ diff --git a/inventory/package-lock.json b/inventory/package-lock.json index 0669f39..898953e 100644 --- a/inventory/package-lock.json +++ b/inventory/package-lock.json @@ -61,6 +61,7 @@ "react-chartjs-2": "^5.3.0", "react-data-grid": "^7.0.0-beta.13", "react-day-picker": "^8.10.1", + "react-debounce-input": "^3.3.0", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", "react-hook-form": "^7.54.2", @@ -6043,6 +6044,12 @@ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", "license": "MIT" }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -6919,6 +6926,19 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-debounce-input": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz", + "integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==", + "license": "MIT", + "dependencies": { + "lodash.debounce": "^4", + "prop-types": "^15.8.1" + }, + "peerDependencies": { + "react": "^15.3.0 || 16 || 17 || 18" + } + }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/inventory/package.json b/inventory/package.json index d68a3a5..db6a0a2 100644 --- a/inventory/package.json +++ b/inventory/package.json @@ -63,6 +63,7 @@ "react-chartjs-2": "^5.3.0", "react-data-grid": "^7.0.0-beta.13", "react-day-picker": "^8.10.1", + "react-debounce-input": "^3.3.0", "react-dom": "^18.3.1", "react-dropzone": "^14.3.5", "react-hook-form": "^7.54.2", diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 7ede6ab..aaef25e 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -18,7 +18,7 @@ import { Import } from '@/pages/Import'; import { AuthProvider } from './contexts/AuthContext'; import { Protected } from './components/auth/Protected'; import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage'; - +import { Brands } from '@/pages/Brands'; const queryClient = new QueryClient(); function App() { @@ -108,6 +108,11 @@ function App() { } /> + + + + } /> diff --git a/inventory/src/components/config.ts b/inventory/src/components/config.ts new file mode 100644 index 0000000..50b7544 --- /dev/null +++ b/inventory/src/components/config.ts @@ -0,0 +1,7 @@ +const config = { + // API base URL - update based on your actual API endpoint + apiUrl: '/api', + // Add other config values as needed +}; + +export default config; \ No newline at end of file diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index a53783a..6d0d634 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -5,9 +5,10 @@ import { Settings, ClipboardList, LogOut, - Users, Tags, FileSpreadsheet, + ShoppingBag, + Truck, } from "lucide-react"; import { IconCrystalBall } from "@tabler/icons-react"; import { @@ -57,9 +58,15 @@ const items = [ url: "/categories", permission: "access:categories" }, + { + title: "Brands", + icon: ShoppingBag, + url: "/brands", + permission: "access:brands" + }, { title: "Vendors", - icon: Users, + icon: Truck, url: "/vendors", permission: "access:vendors" }, diff --git a/inventory/src/components/products/ProductDetail.tsx b/inventory/src/components/products/ProductDetail.tsx index 08663c8..e298612 100644 --- a/inventory/src/components/products/ProductDetail.tsx +++ b/inventory/src/components/products/ProductDetail.tsx @@ -1,900 +1,263 @@ +import * as React from 'react'; import { useQuery } from "@tanstack/react-query"; import { Drawer as VaulDrawer } from "vaul"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Skeleton } from "@/components/ui/skeleton"; -import { Card } from "@/components/ui/card"; +import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { X } from "lucide-react"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; +import { ProductMetric, ProductStatus } from "@/types/products"; +import { + getStatusBadge, + formatCurrency, + formatNumber, + formatPercentage, + formatDays, + formatDate, + formatBoolean, + getProductStatus +} from "@/utils/productUtils"; +import { cn } from "@/lib/utils"; import config from "@/config"; -interface Product { - pid: number; - title: string; - SKU: string; - barcode: string; - created_at: string; - updated_at: string; - - // Inventory fields - stock_quantity: number; - moq: number; - uom: number; - managing_stock: boolean; - replenishable: boolean; - - // Pricing fields - price: number; - regular_price: number; - cost_price: number; - landing_cost_price: number | null; - - // Categorization - categories: string[]; - tags: string[]; - options: Record; - - // Vendor info - vendor: string; - vendor_reference: string; - brand: string | 'Unbranded'; - - // URLs - permalink: string; - image: string | null; - - // Metrics - metrics: { - // Sales metrics - daily_sales_avg: number; - weekly_sales_avg: number; - monthly_sales_avg: number; - - // Inventory metrics - days_of_inventory: number; - reorder_point: number; - safety_stock: number; - stock_status: string; - abc_class: string; - - // Financial metrics - avg_margin_percent: number; - total_revenue: number; - inventory_value: number; - turnover_rate: number; - gmroi: number; - cost_of_goods_sold: number; - gross_profit: number; - - // Lead time metrics - avg_lead_time_days: number; - current_lead_time: number; - target_lead_time: number; - lead_time_status: string; - }; - - // Vendor performance - vendor_performance?: { - avg_lead_time_days: number; - on_time_delivery_rate: number; - order_fill_rate: number; - total_orders: number; - total_late_orders: number; - total_purchase_value: number; - avg_order_value: number; - }; - - // Time series data - monthly_sales?: Array<{ - month: string; - quantity: number; - revenue: number; - cost: number; - avg_price: number; - profit_margin: number; - inventory_value: number; - quantity_growth: number; - revenue_growth: number; - }>; - - recent_orders?: Array<{ - date: string; - order_number: string; - quantity: number; - price: number; - discount: number; - tax: number; - shipping: number; - customer: string; - status: string; - payment_method: string; - }>; - - recent_purchases?: Array<{ - date: string; - expected_date: string; - received_date: string | null; - po_id: string; - ordered: number; - received: number; - status: string; - cost_price: number; - notes: string; - lead_time_days: number | null; - }>; - - category_paths?: Record; - - description?: string; - - preorder_count: number; - notions_inv_count: number; -} - interface ProductDetailProps { productId: number | null; onClose: () => void; } export function ProductDetail({ productId, onClose }: ProductDetailProps) { - const { data: product, isLoading: isLoadingProduct } = useQuery({ - queryKey: ["product", productId], + const { data: product, isLoading: isLoadingProduct, error } = useQuery({ + queryKey: ["productMetricDetail", productId], queryFn: async () => { - if (!productId) return null; - console.log('Fetching product details for:', productId); + if (!productId) throw new Error("Product ID is required"); + const response = await fetch(`${config.apiUrl}/metrics/${productId}`, {credentials: 'include'}); + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(`Failed to fetch product details (${response.status}): ${errorData.error || 'Server error'}`); + } + const rawData = await response.json(); - const response = await fetch(`${config.apiUrl}/products/${productId}`); - if (!response.ok) { - throw new Error("Failed to fetch product details"); + // Transform snake_case to camelCase and convert string numbers to actual numbers + const transformed: any = {}; + Object.entries(rawData).forEach(([key, value]) => { + // Better handling of snake_case to camelCase conversion + let camelKey = key; + + // First handle cases like sales_7d -> sales7d + camelKey = camelKey.replace(/_(\d+[a-z])/g, '$1'); + + // Then handle regular snake_case -> camelCase + camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase()); + + // Convert numeric strings to actual numbers + if (typeof value === 'string' && !isNaN(Number(value)) && + !key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' && + key !== 'brand' && key !== 'vendor') { + transformed[camelKey] = Number(value); + } else { + transformed[camelKey] = value; + } + }); + + // Ensure pid is a number + transformed.pid = typeof transformed.pid === 'string' ? + parseInt(transformed.pid, 10) : transformed.pid; + + // Make sure we have a status + if (!transformed.status) { + transformed.status = getProductStatus(transformed); } - const data = await response.json(); - console.log('Product data:', data); - return data; + + return transformed; }, - enabled: !!productId, + enabled: !!productId, // Only run query when productId is truthy }); - // Separate query for time series data - const { data: timeSeriesData, isLoading: isLoadingTimeSeries } = useQuery({ - queryKey: ["product-time-series", productId], - queryFn: async () => { - if (!productId) return null; - const response = await fetch(`${config.apiUrl}/products/${productId}/time-series`); - if (!response.ok) { - throw new Error("Failed to fetch time series data"); - } - const data = await response.json(); - console.log('Time series data:', data); - return data; - }, - enabled: !!productId, - }); + const isLoading = isLoadingProduct; - const isLoading = isLoadingProduct || isLoadingTimeSeries; - - // Helper function to format price values - const formatPrice = (price: number | null | undefined): string => { - if (price === null || price === undefined) return 'N/A'; - return price.toFixed(2); - }; - - // Helper function to format date values - const formatDate = (date: string | null): string => { - if (!date) return '-'; - return new Date(date).toLocaleDateString('en-US', { - year: 'numeric', - month: 'short', - day: 'numeric' - }); - }; - - // Combine product and time series data - const combinedData = product && timeSeriesData ? { - ...product, - monthly_sales: timeSeriesData.monthly_sales, - recent_orders: timeSeriesData.recent_orders, - recent_purchases: timeSeriesData.recent_purchases - } : product; - - if (!productId) return null; + if (!productId) return null; // Don't render anything if no ID return ( !open && onClose()} direction="right"> - - -
-
- {product?.image && ( -
- {product.title} + + + {/* Header */} +
+
+ {isLoading ? ( + + ) : product?.imageUrl ? ( +
+ {product.title +
+ ) : ( +
No Image
+ )} +
+ + {isLoading ? : product?.title || 'Product Detail'} + + + {isLoading ? : product?.sku || ''} + + {/* Show Status Badge */} + {!isLoading && product && ( +
+ )} +
- )} -
- {product?.title || 'Loading...'} - {product?.SKU || ''} -
-
- -
- - -
- - Overview - Inventory - Sales - Purchase History - Financial - Vendor - Additional Info - +
- - {isLoading ? ( -
- - -
- ) : ( -
- -

Basic Information

-
-
-
Company
-
{product?.brand || "N/A"}
-
-
-
Supplier
-
{product?.vendor || "N/A"}
-
-
-
Supplier #
-
{product?.vendor_reference || "N/A"}
-
-
-
UPC
-
{product?.barcode || "N/A"}
-
- {product?.description && ( -
-
Description
-
{product.description}
+ {/* Content Area */} +
+ {isLoading ? ( +
+ +
+ + + +
- )} -
-
Categories
-
- {product?.category_paths ? - Object.entries(product.category_paths).map(([key, fullPath]) => { - const [, leafCategory] = key.split(':'); - return ( -
- - {leafCategory} - - - {fullPath} - -
- ); - }) - : "N/A"} -
-
-
-
Tags
-
- N/A -
-
-
-
- - -

Pricing

-
-
-
Price
-
${formatPrice(product?.price)}
-
-
-
Default Price
-
${formatPrice(product?.regular_price)}
-
-
-
Cost Price
-
${formatPrice(product?.cost_price)}
-
-
-
Landing Cost
-
${formatPrice(product?.landing_cost_price)}
-
-
-
- - -

Stock Status

-
-
-
Shelf Count
-
{product?.stock_quantity}
-
-
-
Status
-
{product?.metrics?.stock_status || "N/A"}
-
-
-
Days of Inventory
-
{product?.metrics?.days_of_inventory || 0} days
-
-
-
- - -

Sales Velocity

-
-
-
Daily Sales
-
{product?.metrics?.daily_sales_avg?.toFixed(1) || "0.0"} units
-
-
-
Weekly Sales
-
{product?.metrics?.weekly_sales_avg?.toFixed(1) || "0.0"} units
-
-
-
Monthly Sales
-
{product?.metrics?.monthly_sales_avg?.toFixed(1) || "0.0"} units
-
-
-
- - -

Sales Trend

-
- - - - - - - - - - -
-
- - -

Customer Engagement

-
- {product?.total_sold > 0 && ( -
-
Total Sold
-
{product.total_sold}
-
- )} - {product?.rating > 0 && ( -
-
Rating
-
- {product.rating.toFixed(1)} - -
-
- )} - {product?.reviews > 0 && ( -
-
Reviews
-
{product.reviews}
-
- )} - {product?.baskets > 0 && ( -
-
In Baskets
-
{product.baskets}
-
- )} - {product?.notifies > 0 && ( -
-
Notify Requests
-
{product.notifies}
-
- )} - {product?.date_last_sold && ( -
-
Last Sold
-
{formatDate(product.date_last_sold)}
-
- )} -
-
- - -

Financial Metrics

-
-
-
Revenue
-
${formatPrice(product?.metrics?.total_revenue)}
-
-
-
Gross Profit
-
${formatPrice(product?.metrics?.gross_profit)}
-
-
-
Cost of Goods Sold
-
${formatPrice(product?.metrics?.cost_of_goods_sold)}
-
-
-
Margin
-
{product?.metrics?.avg_margin_percent?.toFixed(2) || "0.00"}%
-
-
-
GMROI
-
{product?.metrics?.gmroi?.toFixed(2) || "0.00"}
-
-
-
- - -

Lead Time

-
-
-
Current Lead Time
-
{product?.metrics?.current_lead_time || "N/A"}
-
-
-
Target Lead Time
-
{product?.metrics?.target_lead_time || "N/A"}
-
-
-
Lead Time Status
-
{product?.metrics?.lead_time_status || "N/A"}
-
-
-
-
- )} -
- - - {isLoading ? ( - - ) : ( -
- -

Current Stock

-
-
-
Shelf Count
-
{product?.stock_quantity}
-
-
-
Status
-
{product?.metrics?.stock_status || "N/A"}
-
-
-
Days of Inventory
-
{product?.metrics?.days_of_inventory || 0}
-
- {product?.preorder_count > 0 && ( -
-
Preorders
-
{product?.preorder_count}
-
- )} - {product?.notions_inv_count > 0 && ( -
-
Notions Inventory
-
{product?.notions_inv_count}
-
- )} -
-
- - -

Stock Thresholds

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

Recent Orders

- - - - Date - Order # - Customer - Quantity - Price - Status - - - - {combinedData?.recent_orders?.map((order: NonNullable[number]) => ( - - {formatDate(order.date)} - {order.order_number} - {order.customer} - {order.quantity} - ${formatPrice(order.price)} - {order.status} - - ))} - {(!combinedData?.recent_orders || combinedData.recent_orders.length === 0) && ( - - - No recent orders - - - )} - -
-
- - -

Monthly Sales Trend

-
- - - - - - - - - - - + ) : error ? ( +
+ Error loading product details: {error.message}
- + ) : product ? ( + + + Overview + Inventory + Performance + Details + - -

Customer Engagement

-
- {product?.total_sold > 0 && ( -
-
Total Sold
-
{product.total_sold}
-
- )} - {product?.rating > 0 && ( -
-
Rating
-
- {product.rating.toFixed(1)} - -
-
- )} - {product?.reviews > 0 && ( -
-
Reviews
-
{product.reviews}
-
- )} - {product?.baskets > 0 && ( -
-
In Baskets
-
{product.baskets}
-
- )} - {product?.notifies > 0 && ( -
-
Notify Requests
-
{product.notifies}
-
- )} - {product?.date_last_sold && ( -
-
Last Sold
-
{formatDate(product.date_last_sold)}
-
- )} -
-
-
- )} - + + + Key Info + + + + + + + + + + + Pricing & Cost + + + + + + + + - - {isLoading ? ( - - ) : ( -
- -

Recent Purchase Orders

- - - - Date - PO # - Ordered - Received - Status - Lead Time - - - - {combinedData?.recent_purchases?.map((po: NonNullable[number]) => ( - - {formatDate(po.date)} - {po.po_id} - {po.ordered} - {po.received} - {po.status} - {po.lead_time_days ? `${po.lead_time_days} days` : 'N/A'} - - ))} - {(!combinedData?.recent_purchases || combinedData.recent_purchases.length === 0) && ( - - - No recent purchase orders - - - )} - -
-
-
- )} -
+ + + Current Stock + + + + + + + + Stock Position + + } /> + + + + + + + + + On Order + + + + + + + - - {isLoading ? ( - - ) : ( -
- -

Financial Overview

-
-
-
Gross Profit
-
${formatPrice(product?.metrics?.gross_profit)}
-
-
-
GMROI
-
{product?.metrics?.gmroi?.toFixed(2) || "0.00"}
-
-
-
Margin %
-
{product?.metrics?.avg_margin_percent?.toFixed(2) || "0.00"}%
-
-
-
+ + + Sales Performance (30 Days) + + + + + + + + + + + + Inventory Performance (30 Days) + + + + + + + + + + + Historical Performance + + + + + + + - -

Cost Breakdown

-
-
-
Cost of Goods Sold
-
${formatPrice(product?.metrics?.cost_of_goods_sold)}
-
-
-
Landing Cost
-
${formatPrice(product?.landing_cost_price)}
-
-
-
- - -

Profit Margin Trend

-
- - - - - - - - - -
-
-
- )} -
- - - {isLoading ? ( - - ) : product?.vendor_performance ? ( -
- -

Vendor Performance

-
-
-
Lead Time
-
{product?.vendor_performance?.avg_lead_time_days?.toFixed(1) || "N/A"} days
-
-
-
On-Time Delivery
-
{product?.vendor_performance?.on_time_delivery_rate?.toFixed(1) || "N/A"}%
-
-
-
Order Fill Rate
-
{product?.vendor_performance?.order_fill_rate?.toFixed(1) || "N/A"}%
-
-
-
Total Orders
-
{product?.vendor_performance?.total_orders || 0}
-
-
-
- - -

Order History

-
-
-
Total Orders
-
{product?.vendor_performance?.total_orders}
-
-
-
Late Orders
-
{product?.vendor_performance?.total_late_orders}
-
-
-
Total Purchase Value
-
${formatPrice(product?.vendor_performance?.total_purchase_value)}
-
-
-
Avg Order Value
-
${formatPrice(product?.vendor_performance?.avg_order_value)}
-
-
-
-
- ) : ( -
No vendor performance data available
- )} -
- - - {isLoading ? ( - - ) : ( -
- -

Product Details

-
- {product?.description && ( -
-
Description
-
{product.description}
-
- )} -
-
Created Date
-
{formatDate(product?.created_at)}
-
-
-
Last Updated
-
{formatDate(product?.updated_at)}
-
-
-
Product ID
-
{product?.pid}
-
-
-
Line
-
{product?.line || 'N/A'}
-
-
-
Subline
-
{product?.subline || 'N/A'}
-
-
-
Artist
-
{product?.artist || 'N/A'}
-
-
-
Country of Origin
-
{product?.country_of_origin || 'N/A'}
-
-
-
Location
-
{product?.location || 'N/A'}
-
-
-
HTS Code
-
{product?.harmonized_tariff_code || 'N/A'}
-
-
-
Notions Reference
-
{product?.notions_reference || 'N/A'}
-
-
-
- - -

Physical Attributes

-
-
-
Weight
-
{product?.weight ? `${product.weight} kg` : 'N/A'}
-
-
-
Dimensions
-
- {product?.dimensions - ? `${product.dimensions.length} × ${product.dimensions.width} × ${product.dimensions.height} cm` - : 'N/A' - } -
-
-
-
- - -

Customer Metrics

-
-
-
Rating
-
- {product?.rating - ? <> - {product.rating.toFixed(1)} - - - : 'N/A' - } -
-
-
-
Review Count
-
{product?.reviews || 'N/A'}
-
-
-
Total Sold
-
{product?.total_sold || 'N/A'}
-
-
-
Currently in Baskets
-
{product?.baskets || 'N/A'}
-
-
-
Notify Requests
-
{product?.notifies || 'N/A'}
-
-
-
Date Last Sold
-
{formatDate(product?.date_last_sold) || 'N/A'}
-
-
-
-
- )} -
- + + + Dates + + + + + + + + + + + + Configuration + + + + + + + + ) : null} +
); -} \ No newline at end of file +} + +// Helper component for consistent display in detail view +const InfoItem = ({ label, value, isLarge = false }: { label: string; value: React.ReactNode, isLarge?: boolean }) => ( +
+
{label}
+
{value}
+
+); \ No newline at end of file diff --git a/inventory/src/components/products/ProductFilters.tsx b/inventory/src/components/products/ProductFilters.tsx index e677f8a..a47b717 100644 --- a/inventory/src/components/products/ProductFilters.tsx +++ b/inventory/src/components/products/ProductFilters.tsx @@ -8,7 +8,6 @@ import { CommandInput, CommandItem, CommandList, - CommandSeparator, } from "@/components/ui/command"; import { X, Plus } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -19,6 +18,8 @@ import { } from "@/components/ui/popover"; import { Input } from "@/components/ui/input"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ProductFilterOptions, ProductMetricColumnKey } from "@/types/products"; type FilterValue = string | number | boolean; type ComparisonOperator = "=" | ">" | ">=" | "<" | "<=" | "between"; @@ -29,379 +30,93 @@ interface FilterValueWithOperator { } // Support both simple values and complex ones with operators -export type ActiveFilterValue = FilterValue | FilterValueWithOperator | [number, number]; +export type ActiveFilterValue = FilterValue | FilterValueWithOperator; -interface ActiveFilter { +interface ActiveFilterDisplay { id: string; label: string; value: ActiveFilterValue; displayValue: string; } -interface FilterOption { - id: string; +export interface FilterOption { + id: ProductMetricColumnKey | 'search'; label: string; - type: "select" | "number" | "boolean" | "text"; + type: "select" | "number" | "boolean" | "text" | "date"; options?: { label: string; value: string }[]; group: string; operators?: ComparisonOperator[]; } -const FILTER_OPTIONS: FilterOption[] = [ +// Base filter options - static part of the filters, which will be merged with dynamic options +const BASE_FILTER_OPTIONS: FilterOption[] = [ + // Search Group + { id: "search", label: "Search (Title, SKU...)", type: "text", group: "Search" }, + // Basic Info Group - { id: "search", label: "Search", type: "text", group: "Basic Info" }, { id: "sku", label: "SKU", type: "text", group: "Basic Info" }, - { id: "barcode", label: "UPC/Barcode", type: "text", group: "Basic Info" }, { id: "vendor", label: "Vendor", type: "select", group: "Basic Info" }, - { id: "vendor_reference", label: "Supplier #", type: "text", group: "Basic Info" }, { id: "brand", label: "Brand", type: "select", group: "Basic Info" }, - { id: "category", label: "Category", type: "select", group: "Basic Info" }, - { id: "description", label: "Description", type: "text", group: "Basic Info" }, - { id: "harmonized_tariff_code", label: "HTS Code", type: "text", group: "Basic Info" }, - { id: "notions_reference", label: "Notions Ref", type: "text", group: "Basic Info" }, - { id: "line", label: "Line", type: "text", group: "Basic Info" }, - { id: "subline", label: "Subline", type: "text", group: "Basic Info" }, - { id: "artist", label: "Artist", type: "text", group: "Basic Info" }, - { id: "country_of_origin", label: "Origin", type: "text", group: "Basic Info" }, - { id: "location", label: "Location", type: "text", group: "Basic Info" }, - - // Physical Properties - { - id: "weight", - label: "Weight", - type: "number", - group: "Physical Properties", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - - // Inventory Group - { - id: "stockStatus", - label: "Stock Status", - type: "select", - options: [ - { label: "Critical", value: "critical" }, - { label: "At Risk", value: "at-risk" }, - { label: "Reorder", value: "reorder" }, - { label: "Healthy", value: "healthy" }, - { label: "Overstocked", value: "overstocked" }, - { label: "New", value: "new" }, - ], - group: "Inventory", - }, - { - id: "stock", - label: "Stock Quantity", - type: "number", - group: "Inventory", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "preorderCount", - label: "Preorder Count", - type: "number", - group: "Inventory", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "notionsInvCount", - label: "Notions Inventory", - type: "number", - group: "Inventory", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "daysOfStock", - label: "Days of Stock", - type: "number", - group: "Inventory", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "weeksOfStock", - label: "Weeks of Stock", - type: "number", - group: "Inventory", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "reorderPoint", - label: "Reorder Point", - type: "number", - group: "Inventory", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "safetyStock", - label: "Safety Stock", - type: "number", - group: "Inventory", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "replenishable", - label: "Replenishable", - type: "select", - options: [ - { label: "Yes", value: "true" }, - { label: "No", value: "false" }, - ], - group: "Inventory", - }, - { - id: "abcClass", - label: "ABC Class", - type: "select", - options: [ - { label: "A", value: "A" }, - { label: "B", value: "B" }, - { label: "C", value: "C" }, - ], - group: "Inventory", - }, + { id: "isVisible", label: "Is Visible", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" }, + { id: "isReplenishable", label: "Is Replenishable", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Basic Info" }, + { id: "dateCreated", label: "Date Created", type: "date", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "ageDays", label: "Age (Days)", type: "number", group: "Basic Info", operators: ["=", ">", ">=", "<", "<=", "between"] }, + // Status Group + // { id: "status", label: "Status", type: "select", options: [...] } - Will be populated dynamically or handled via views + + // Stock Group + { id: "abcClass", label: "ABC Class", type: "select", group: "Stock" }, + { id: "currentStock", label: "Current Stock", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "currentStockCost", label: "Stock Cost", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "currentStockRetail", label: "Stock Retail", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "onOrderQty", label: "On Order Qty", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "stockCoverInDays", label: "Stock Cover (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "sellsOutInDays", label: "Sells Out In (Days)", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "overstockedUnits", label: "Overstock Units", type: "number", group: "Stock", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "isOldStock", label: "Is Old Stock", type: "select", options: [{label: 'Yes', value: 'true'}, {label: 'No', value: 'false'}], group: "Stock" }, + // Pricing Group - { - id: "price", - label: "Price", - type: "number", - group: "Pricing", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "costPrice", - label: "Cost Price", - type: "number", - group: "Pricing", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "landingCost", - label: "Landing Cost", - type: "number", - group: "Pricing", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - + { id: "currentPrice", label: "Current Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "currentCostPrice", label: "Cost Price", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "currentLandingCostPrice", label: "Landing Cost", type: "number", group: "Pricing", operators: ["=", ">", ">=", "<", "<=", "between"] }, + // Sales Metrics Group - { - id: "dailySalesAvg", - label: "Daily Sales Avg", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "weeklySalesAvg", - label: "Weekly Sales Avg", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "monthlySalesAvg", - label: "Monthly Sales Avg", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "avgQuantityPerOrder", - label: "Avg Qty/Order", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "numberOfOrders", - label: "Order Count", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "firstSaleDate", - label: "First Sale Date", - type: "text", - group: "Sales Metrics", - }, - { - id: "lastSaleDate", - label: "Last Sale Date", - type: "text", - group: "Sales Metrics", - }, - { - id: "date_last_sold", - label: "Date Last Sold", - type: "text", - group: "Sales Metrics", - }, - { - id: "total_sold", - label: "Total Sold", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "baskets", - label: "In Baskets", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "notifies", - label: "Notifies", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "rating", - label: "Rating", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "reviews", - label: "Reviews", - type: "number", - group: "Sales Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - - // Financial Metrics Group - { - id: "margin", - label: "Margin %", - type: "number", - group: "Financial Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "gmroi", - label: "GMROI", - type: "number", - group: "Financial Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "inventoryValue", - label: "Inventory Value", - type: "number", - group: "Financial Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "costOfGoodsSold", - label: "COGS", - type: "number", - group: "Financial Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "grossProfit", - label: "Gross Profit", - type: "number", - group: "Financial Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "turnoverRate", - label: "Turnover Rate", - type: "number", - group: "Financial Metrics", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - - // Lead Time & Stock Coverage Group - { - id: "leadTime", - label: "Lead Time (Days)", - type: "number", - group: "Lead Time & Coverage", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "currentLeadTime", - label: "Current Lead Time", - type: "number", - group: "Lead Time & Coverage", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "targetLeadTime", - label: "Target Lead Time", - type: "number", - group: "Lead Time & Coverage", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "leadTimeStatus", - label: "Lead Time Status", - type: "select", - options: [ - { label: "On Target", value: "on_target" }, - { label: "Warning", value: "warning" }, - { label: "Critical", value: "critical" }, - ], - group: "Lead Time & Coverage", - }, - { - id: "stockCoverage", - label: "Stock Coverage Ratio", - type: "number", - group: "Lead Time & Coverage", - operators: ["=", ">", ">=", "<", "<=", "between"], - }, - { - id: "lastPurchaseDate", - label: "Last Purchase Date", - type: "text", - group: "Lead Time & Coverage", - }, - { - id: "firstReceivedDate", - label: "First Received Date", - type: "text", - group: "Lead Time & Coverage", - }, - { - id: "lastReceivedDate", - label: "Last Received Date", - type: "text", - group: "Lead Time & Coverage", - }, - - // Classification Group - { - id: "managingStock", - label: "Managing Stock", - type: "select", - options: [ - { label: "Yes", value: "true" }, - { label: "No", value: "false" }, - ], - group: "Classification", - }, + { id: "sales7d", label: "Sales (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "revenue7d", label: "Revenue (7d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "sales30d", label: "Sales (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "revenue30d", label: "Revenue (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "profit30d", label: "Profit (30d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "sales365d", label: "Sales (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "revenue365d", label: "Revenue (365d)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "salesVelocityDaily", label: "Daily Velocity", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "dateLastSold", label: "Date Last Sold", type: "date", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "yesterdaySales", label: "Sales (Yesterday)", type: "number", group: "Sales Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + + // Financial KPIs + { id: "margin30d", label: "Margin % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "gmroi30d", label: "GMROI (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "stockturn30d", label: "Stock Turn (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "sellThrough30d", label: "Sell Thru % (30d)", type: "number", group: "Financial Metrics", operators: ["=", ">", ">=", "<", "<=", "between"] }, + + // Lead Time & Replenishment + { id: "avgLeadTimeDays", label: "Avg Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "configLeadTime", label: "Config Lead Time", type: "number", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "earliestExpectedDate", label: "Next Arrival Date", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] }, + { id: "dateLastReceived", label: "Date Last Received", type: "date", group: "Lead Time", operators: ["=", ">", ">=", "<", "<=", "between"] }, ]; interface ProductFiltersProps { - categories: string[]; - vendors: string[]; - brands: string[]; + filterOptions: ProductFilterOptions; + isLoadingOptions: boolean; onFilterChange: (filters: Record) => void; onClearFilters: () => void; activeFilters: Record; } export function ProductFilters({ - categories, - vendors, - brands, + filterOptions, + isLoadingOptions, onFilterChange, onClearFilters, activeFilters, @@ -410,15 +125,14 @@ export function ProductFilters({ const [selectedFilter, setSelectedFilter] = React.useState(null); const [selectedOperator, setSelectedOperator] = React.useState("="); const [inputValue, setInputValue] = React.useState(""); - const [inputValue2, setInputValue2] = React.useState(""); + const [inputValue2, setInputValue2] = React.useState(""); // For 'between' operator const [searchValue, setSearchValue] = React.useState(""); - - // Add refs for the inputs + const numberInputRef = React.useRef(null); const selectInputRef = React.useRef(null); const textInputRef = React.useRef(null); + const dateInputRef = React.useRef(null); - // Reset states when popup closes const handlePopoverClose = () => { setShowCommand(false); setSelectedFilter(null); @@ -431,93 +145,98 @@ export function ProductFilters({ // 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(); - if (!showCommand) { - setShowCommand(true); - } else { + setShowCommand(prev => !prev); + if (showCommand) { handlePopoverClose(); } } }; - window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [showCommand]); - // Update filter options with dynamic data - const filterOptions = React.useMemo(() => { - return FILTER_OPTIONS.map((option) => { - if (option.id === "category") { - return { - ...option, - options: categories.map((cat) => ({ label: cat, value: cat })), - }; + // Update filter options with dynamic data from props + const processedFilterOptions = React.useMemo(() => { + // Create a safe version of filterOptions with defaults to prevent undefined errors + const safeFilterOptions = { + vendors: filterOptions?.vendors || [], + brands: filterOptions?.brands || [], + abcClasses: filterOptions?.abcClasses || [] + }; + + return BASE_FILTER_OPTIONS.map((option) => { + const newOption = { ...option }; // Create mutable copy + if (option.id === "vendor" && option.type === "select") { + newOption.options = safeFilterOptions.vendors.map((v) => ({ label: v, value: v })); + } else if (option.id === "brand" && option.type === "select") { + newOption.options = safeFilterOptions.brands.map((b) => ({ label: b, value: b })); + } else if (option.id === "abcClass" && option.type === "select") { + newOption.options = safeFilterOptions.abcClasses.map((abc) => ({ label: abc, value: abc })); } - if (option.id === "vendor") { - return { - ...option, - options: vendors.map((vendor) => ({ label: vendor, value: vendor })), - }; - } - if (option.id === "brand") { - return { - ...option, - options: brands.map((brand) => ({ label: brand, value: brand })), - }; - } - return option; + return newOption; }); - }, [categories, vendors, brands]); + }, [filterOptions]); // Filter options based on search const filteredOptions = React.useMemo(() => { - if (!searchValue) return filterOptions; - + if (!searchValue) return processedFilterOptions; const search = searchValue.toLowerCase(); - return filterOptions.filter( + return processedFilterOptions.filter( (option) => option.label.toLowerCase().includes(search) || option.group.toLowerCase().includes(search) ); - }, [filterOptions, searchValue]); + }, [processedFilterOptions, searchValue]); const handleSelectFilter = React.useCallback((filter: FilterOption) => { setSelectedFilter(filter); setInputValue(""); - + setInputValue2(""); + // Set default operator if the filter type supports operators + setSelectedOperator(filter.operators?.[0] || "="); + // Focus the appropriate input after state updates requestAnimationFrame(() => { - if (filter.type === "number") { - numberInputRef.current?.focus(); - } else if (filter.type === "select") { - selectInputRef.current?.focus(); - } else { - textInputRef.current?.focus(); - } + if (filter.type === "number") numberInputRef.current?.focus(); + else if (filter.type === "select") selectInputRef.current?.focus(); + else if (filter.type === "date") dateInputRef.current?.focus(); + else textInputRef.current?.focus(); }); }, []); - const handleApplyFilter = (value: FilterValue | [number, number]) => { + const handleApplyFilter = () => { if (!selectedFilter) return; + let valueToApply: FilterValue | [string, string]; // Use string for dates + let requiresOperator = selectedFilter.type === 'number' || selectedFilter.type === 'date'; + + if (selectedOperator === 'between') { + if (!inputValue || !inputValue2) return; // Need both values + valueToApply = [inputValue, inputValue2]; + requiresOperator = true; // Always requires operator for between + } else if (selectedFilter.type === 'number') { + const numVal = parseFloat(inputValue); + if (isNaN(numVal)) return; // Invalid number + valueToApply = numVal; + } else if (selectedFilter.type === 'boolean' || selectedFilter.type === 'select') { + valueToApply = inputValue; // Value set directly via CommandItem select + requiresOperator = false; // Usually simple equality for selects/booleans + } else { // Text or Date (not between) + if (!inputValue.trim()) return; + valueToApply = inputValue.trim(); + } + let filterValue: ActiveFilterValue; - if (selectedOperator) { - if (selectedOperator === "between" && Array.isArray(value)) { - filterValue = { - value: value.map(v => v.toString()), - operator: selectedOperator, - }; - } else { - filterValue = { - value: typeof value === 'number' ? value : value.toString(), - operator: selectedOperator, - }; - } + if (requiresOperator) { + filterValue = { + value: valueToApply, + operator: selectedOperator, + }; } else { - filterValue = Array.isArray(value) ? value : value; + filterValue = valueToApply as FilterValue; } onFilterChange({ @@ -528,6 +247,13 @@ export function ProductFilters({ handlePopoverClose(); }; + // Specific handler for select options to bypass operator selection + const handleApplySelectFilter = (value: string) => { + if (!selectedFilter) return; + onFilterChange({ ...activeFilters, [selectedFilter.id]: value }); + handlePopoverClose(); + }; + const handleBackToFilters = () => { setSelectedFilter(null); setSelectedOperator("="); @@ -535,335 +261,246 @@ export function ProductFilters({ setInputValue2(""); }; - const activeFiltersList = React.useMemo(() => { - if (!activeFilters) return []; + // Get display value for filter badges + const getFilterDisplayValue = (id: string, value: ActiveFilterValue): string => { + const option = processedFilterOptions.find(opt => opt.id === id); + if (!option) return String(value); // Fallback - return Object.entries(activeFilters).map(([id, value]): ActiveFilter => { - const option = filterOptions.find((opt) => opt.id === id); - let displayValue = String(value); - - if (option?.type === "select" && option.options) { - const optionLabel = option.options.find( - (opt) => opt.value === value - )?.label; - if (optionLabel) displayValue = optionLabel; + if (typeof value === 'object' && value !== null && 'operator' in value) { + const opLabel = value.operator === '=' ? '' : `${value.operator} `; + if (value.operator === 'between' && Array.isArray(value.value)) { + return `${option.label}: ${opLabel} ${value.value[0]} and ${value.value[1]}`; } + return `${option.label}: ${opLabel}${value.value}`; + } - return { - id, - label: option?.label || id, - value, - displayValue, - }; - }); - }, [activeFilters, filterOptions]); + // Handle simple values (selects, booleans, text) + if (option.type === 'select' || option.type === 'boolean') { + const selectedOpt = option.options?.find(opt => opt.value === String(value)); + return `${option.label}: ${selectedOpt?.label || String(value)}`; + } + return `${option.label}: ${String(value)}`; + }; + + const activeFiltersList = React.useMemo((): ActiveFilterDisplay[] => { + return Object.entries(activeFilters) + .map(([id, value]): ActiveFilterDisplay | null => { + const option = processedFilterOptions.find(opt => opt.id === id); + if (!option) return null; // Should not happen if state is clean + + return { + id, + label: option.label, + value, + displayValue: getFilterDisplayValue(id, value), + }; + }) + .filter((f): f is ActiveFilterDisplay => f !== null); // Type guard to remove nulls + }, [activeFilters, processedFilterOptions]); + + // Render operator select const renderOperatorSelect = () => ( - value && setSelectedOperator(value) - } - className="flex-wrap" + onValueChange={(value: ComparisonOperator) => value && setSelectedOperator(value)} + className="flex-wrap justify-start gap-1" + size="sm" > - - = - - - {">"} - - - ≥ - - - {"<"} - - - ≤ - - - Between - + {selectedFilter?.operators?.map(op => ( + + {op === '=' ? '=' : op === '>=' ? '≥' : op === '<=' ? '≤' : op} + + )) || ( + = + )} ); - - const getFilterDisplayValue = (filter: ActiveFilter) => { - if (typeof filter.value === "object" && "operator" in filter.value) { - const { operator, value } = filter.value; - if (Array.isArray(value)) { - return `${operator} ${value[0]} and ${value[1]}`; - } - return `${operator} ${value}`; - } - return filter.value.toString(); - }; - return ( -
+
- { - if (!open) { - handlePopoverClose(); - } else { - setShowCommand(true); - } - }} - modal={true} - > + { - console.log('Escape pressed, selectedFilter:', selectedFilter); // Debug log if (selectedFilter) { - event.preventDefault(); - event.stopPropagation(); + event.preventDefault(); // Prevent popover closing handleBackToFilters(); } }} + onCloseAutoFocus={(e) => e.preventDefault()} // Prevent refocus on trigger > {!selectedFilter ? ( <> { - if (e.key === "Escape") { - e.preventDefault(); - handlePopoverClose(); - } - }} /> - No filters found. - {Object.entries( - filteredOptions.reduce>( - (acc, filter) => { - if (!acc[filter.group]) acc[filter.group] = []; - acc[filter.group].push(filter); - return acc; - }, - {} - ) - ).map(([group, filters]) => ( - - - {filters.map((filter) => ( - { - handleSelectFilter(filter); - if (filter.type !== "select") { - setInputValue(""); - } - }} - className={cn( - "cursor-pointer", - activeFilters?.[filter.id] && "bg-accent" - )} - > - {filter.label} - {activeFilters?.[filter.id] && ( - - Active - - )} - - ))} - - - - ))} - - - ) : selectedFilter.type === "number" ? ( -
-
-
- -
- {renderOperatorSelect()} -
- setInputValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - if (selectedOperator === "between") { - if (inputValue2) { - const val1 = parseFloat(inputValue); - const val2 = parseFloat(inputValue2); - if (!isNaN(val1) && !isNaN(val2)) { - handleApplyFilter([val1, val2]); - } - } - } else { - const val = parseFloat(inputValue); - if (!isNaN(val)) { - handleApplyFilter(val); - } - } - } else if (e.key === "Escape") { - e.preventDefault(); - handleBackToFilters(); - } - }} - className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> - {selectedOperator === "between" && ( - <> - and - setInputValue2(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - const val1 = parseFloat(inputValue); - const val2 = parseFloat(inputValue2); - if (!isNaN(val1) && !isNaN(val2)) { - handleApplyFilter([val1, val2]); - } - } else if (e.key === "Escape") { - e.preventDefault(); - handleBackToFilters(); - } - }} - className="w-[120px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" - /> - - )} - -
-
-
- ) : selectedFilter.type === "select" ? ( - <> - { - if (e.key === "Backspace" && !inputValue) { - e.preventDefault(); - handleBackToFilters(); - } else if (e.key === "Escape") { - e.preventDefault(); - handleBackToFilters(); - } - }} - /> - - No options found. - - - ← Back to filters - - - {selectedFilter.options - ?.filter((option) => - option.label - .toLowerCase() - .includes(inputValue.toLowerCase()) - ) - .map((option) => ( - handleApplyFilter(option.value)} - className="cursor-pointer" - > - {option.label} - + {isLoadingOptions ? ( +
+ + + +
+ ) : ( + <> + No filters found. + {Object.entries( + filteredOptions.reduce>( + (acc, filter) => { + if (!acc[filter.group]) acc[filter.group] = []; + acc[filter.group].push(filter); + return acc; + }, {} + ) + ).map(([group, filtersInGroup]) => ( + + {filtersInGroup.map((filter) => ( + handleSelectFilter(filter)} + className={cn("cursor-pointer", activeFilters?.[filter.id] && "font-semibold")} + > + {filter.label} + + ))} + ))} -
+ + )}
) : ( - <> - { - if (e.key === "Enter" && inputValue.trim()) { - handleApplyFilter(inputValue.trim()); - } else if (e.key === "Escape") { - e.preventDefault(); - handleBackToFilters(); - } - }} - /> - - - - ← Back to filters - - - {inputValue.trim() && ( - handleApplyFilter(inputValue.trim())} - className="cursor-pointer" - > - Apply filter: {inputValue} - - )} - - - +
+ + + {/* Render Operator Select ONLY if type is number or date */} + {(selectedFilter.type === 'number' || selectedFilter.type === 'date') && renderOperatorSelect()} + + {/* Render Input based on type */} +
+ {selectedFilter.type === 'number' && ( + <> + setInputValue(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleApplyFilter(); }} + className="h-8 flex-1 min-w-0" + /> + {selectedOperator === 'between' && ( + <> + and + setInputValue2(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleApplyFilter(); }} + className="h-8 flex-1 min-w-0" + /> + + )} + + )} + {selectedFilter.type === 'date' && ( + <> + setInputValue(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleApplyFilter(); }} + className="h-8 flex-1 min-w-0" + /> + {selectedOperator === 'between' && ( + <> + and + setInputValue2(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleApplyFilter(); }} + className="h-8 flex-1 min-w-0" + /> + + )} + + )} + {selectedFilter.type === 'text' && ( + setInputValue(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleApplyFilter(); }} + className="h-8 flex-1" + /> + )} + + {/* Apply button for Number, Date, Text */} + {(selectedFilter.type === 'number' || selectedFilter.type === 'date' || selectedFilter.type === 'text') && ( + + )} + + {/* Select and Boolean types are handled via CommandList below */} +
+ + {/* CommandList for Select and Boolean */} + {(selectedFilter.type === 'select' || selectedFilter.type === 'boolean') && ( + + + + No options found. + + {selectedFilter.options + ?.filter(option => + option.label.toLowerCase().includes(inputValue.toLowerCase()) + ) + .map(option => ( + handleApplySelectFilter(option.value)} + className="cursor-pointer" + > + {option.label} + + ))} + + + + )} +
)}
@@ -872,17 +509,16 @@ export function ProductFilters({ - {getFilterDisplayValue(filter)} + {filter.displayValue}