From 0ba7d9f53328979682cb841bc92e436147f0b083 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 17 Jan 2025 00:12:34 -0500 Subject: [PATCH] Align badges and fix search --- inventory-server/src/routes/categories.js | 2 +- inventory-server/src/routes/vendors.js | 2 +- inventory/src/pages/Categories.tsx | 173 ++++++++++++++++----- inventory/src/pages/Vendors.tsx | 176 +++++++++++++++++----- 4 files changed, 272 insertions(+), 81 deletions(-) diff --git a/inventory-server/src/routes/categories.js b/inventory-server/src/routes/categories.js index bfa3d0a..7e13de5 100644 --- a/inventory-server/src/routes/categories.js +++ b/inventory-server/src/routes/categories.js @@ -19,7 +19,7 @@ router.get('/', async (req, res) => { const params = []; if (search) { - whereConditions.push('(c.name LIKE ? OR c.description LIKE ?)'); + whereConditions.push('(LOWER(c.name) LIKE LOWER(?) OR LOWER(c.description) LIKE LOWER(?))'); params.push(`%${search}%`, `%${search}%`); } diff --git a/inventory-server/src/routes/vendors.js b/inventory-server/src/routes/vendors.js index 0bacf66..2a40eae 100644 --- a/inventory-server/src/routes/vendors.js +++ b/inventory-server/src/routes/vendors.js @@ -19,7 +19,7 @@ router.get('/', async (req, res) => { const params = []; if (search) { - whereConditions.push('(p.vendor LIKE ? OR vd.contact_name LIKE ?)'); + whereConditions.push('(LOWER(p.vendor) LIKE LOWER(?) OR LOWER(vd.contact_name) LIKE LOWER(?))'); params.push(`%${search}%`, `%${search}%`); } diff --git a/inventory/src/pages/Categories.tsx b/inventory/src/pages/Categories.tsx index 6ecb17b..1d85cbb 100644 --- a/inventory/src/pages/Categories.tsx +++ b/inventory/src/pages/Categories.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -37,27 +37,101 @@ export function Categories() { parent: "all", performance: "all", }); + const [sorting, setSorting] = useState({ + column: 'name', + direction: 'asc' + }); const { data, isLoading } = useQuery({ - queryKey: ["categories", page, sortColumn, sortDirection, filters], + queryKey: ["categories"], queryFn: async () => { - const searchParams = new URLSearchParams({ - page: page.toString(), - limit: "50", - sortColumn: sortColumn.toString(), - sortDirection, - ...filters.search && { search: filters.search }, - ...filters.parent !== "all" && { parent: filters.parent }, - ...filters.performance !== "all" && { performance: filters.performance }, - }); - - const response = await fetch(`${config.apiUrl}/categories?${searchParams}`); + const response = await fetch(`${config.apiUrl}/categories`); if (!response.ok) throw new Error("Failed to fetch categories"); return response.json(); }, - placeholderData: keepPreviousData, }); + // Filter and sort the data client-side + const filteredData = useMemo(() => { + if (!data?.categories) return []; + + let filtered = [...data.categories]; + + // Apply search filter + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + filtered = filtered.filter(category => + category.name.toLowerCase().includes(searchLower) || + (category.description?.toLowerCase().includes(searchLower)) + ); + } + + // Apply parent filter + if (filters.parent !== 'all') { + if (filters.parent === 'none') { + filtered = filtered.filter(category => !category.parent_category); + } else { + filtered = filtered.filter(category => category.parent_category === filters.parent); + } + } + + // Apply performance filter + if (filters.performance !== 'all') { + filtered = filtered.filter(category => { + const growth = category.growth_rate ?? 0; + switch (filters.performance) { + case 'high_growth': return growth >= 20; + case 'growing': return growth >= 5 && growth < 20; + case 'stable': return growth >= -5 && growth < 5; + case 'declining': return growth < -5; + default: return true; + } + }); + } + + // Apply sorting + filtered.sort((a, b) => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + + if (typeof aVal === 'number' && typeof bVal === 'number') { + return sortDirection === 'asc' ? aVal - bVal : bVal - aVal; + } + + const aStr = String(aVal || ''); + const bStr = String(bVal || ''); + return sortDirection === 'asc' ? + aStr.localeCompare(bStr) : + bStr.localeCompare(aStr); + }); + + return filtered; + }, [data?.categories, filters, sortColumn, sortDirection]); + + // Calculate pagination + const paginatedData = useMemo(() => { + const startIndex = (page - 1) * 50; + return filteredData.slice(startIndex, startIndex + 50); + }, [filteredData, page]); + + // Calculate stats from filtered data + const stats = useMemo(() => { + if (!filteredData.length) return data?.stats; + + const activeCategories = filteredData.filter(c => c.status === 'active').length; + const totalValue = filteredData.reduce((sum, c) => sum + (c.total_value || 0), 0); + const margins = filteredData.map(c => c.avg_margin || 0).filter(m => m !== 0); + const growthRates = filteredData.map(c => c.growth_rate || 0).filter(g => g !== 0); + + return { + totalCategories: filteredData.length, + activeCategories, + totalValue, + avgMargin: margins.length ? margins.reduce((a, b) => a + b, 0) / margins.length : 0, + avgGrowth: growthRates.length ? growthRates.reduce((a, b) => a + b, 0) / growthRates.length : 0 + }; + }, [filteredData, data?.stats]); + const handleSort = (column: keyof Category) => { setSortDirection(prev => { if (sortColumn !== column) return "asc"; @@ -83,23 +157,40 @@ export function Categories() { }; return ( - -
+ +

Categories

- {data?.pagination.total.toLocaleString() ?? "..."} categories + {filteredData.length.toLocaleString()} categories
-
+
-
+ Total Categories -
{data?.stats?.totalCategories ?? "..."}
+
{stats?.totalCategories ?? "..."}

- {data?.stats?.activeCategories ?? "..."} active + {stats?.activeCategories ?? "..."} active

@@ -139,7 +230,7 @@ export function Categories() {

-
+
@@ -203,7 +294,7 @@ export function Categories() { Loading categories... - ) : data?.categories?.map((category: Category) => ( + ) : paginatedData.map((category: Category) => (
{category.name}
@@ -215,15 +306,17 @@ export function Categories() { {typeof category.avg_margin === 'number' ? category.avg_margin.toFixed(1) : "0.0"}% {typeof category.turnover_rate === 'number' ? category.turnover_rate.toFixed(1) : "0.0"}x -
- {typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}% +
+
+ {typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}% +
{getPerformanceBadge(category.growth_rate ?? 0)}
{category.status} ))} - {!isLoading && !data?.categories.length && ( + {!isLoading && !paginatedData.length && ( No categories found @@ -234,8 +327,12 @@ export function Categories() {
- {data?.pagination.pages > 1 && ( -
+ {filteredData.length > 0 && ( + @@ -243,22 +340,22 @@ export function Categories() { href="#" onClick={(e) => { e.preventDefault(); - setPage(p => Math.max(1, p - 1)); + if (page > 1) setPage(p => p - 1); }} aria-disabled={page === 1} /> - {Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => ( - + {Array.from({ length: Math.ceil(filteredData.length / 50) }, (_, i) => ( + { e.preventDefault(); - setPage(p); + setPage(i + 1); }} + isActive={page === i + 1} > - {p} + {i + 1} ))} @@ -267,14 +364,14 @@ export function Categories() { href="#" onClick={(e) => { e.preventDefault(); - setPage(p => Math.min(data.pagination.pages, p + 1)); + if (page < Math.ceil(filteredData.length / 50)) setPage(p => p + 1); }} - aria-disabled={page === data.pagination.pages} + aria-disabled={page >= Math.ceil(filteredData.length / 50)} /> -
+ )} ); diff --git a/inventory/src/pages/Vendors.tsx b/inventory/src/pages/Vendors.tsx index 75e4694..c671526 100644 --- a/inventory/src/pages/Vendors.tsx +++ b/inventory/src/pages/Vendors.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { useState, useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; @@ -38,27 +38,98 @@ export function Vendors() { status: "all", performance: "all", }); + const [sorting, setSorting] = useState({ + column: 'name', + direction: 'asc' + }); const { data, isLoading } = useQuery({ - queryKey: ["vendors", page, sortColumn, sortDirection, filters], + queryKey: ["vendors"], queryFn: async () => { - const searchParams = new URLSearchParams({ - page: page.toString(), - limit: "50", - sortColumn: sortColumn.toString(), - sortDirection, - ...filters.search && { search: filters.search }, - ...filters.status !== "all" && { status: filters.status }, - ...filters.performance !== "all" && { performance: filters.performance }, - }); - - const response = await fetch(`${config.apiUrl}/vendors?${searchParams}`); + const response = await fetch(`${config.apiUrl}/vendors`); if (!response.ok) throw new Error("Failed to fetch vendors"); return response.json(); }, - placeholderData: keepPreviousData, }); + // Filter and sort the data client-side + const filteredData = useMemo(() => { + if (!data?.vendors) return []; + + let filtered = [...data.vendors]; + + // Apply search filter + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + filtered = filtered.filter(vendor => + vendor.name.toLowerCase().includes(searchLower) || + vendor.contact_name?.toLowerCase().includes(searchLower) || + vendor.email?.toLowerCase().includes(searchLower) + ); + } + + // Apply status filter + if (filters.status !== 'all') { + filtered = filtered.filter(vendor => vendor.status === filters.status); + } + + // Apply performance filter + if (filters.performance !== 'all') { + filtered = filtered.filter(vendor => { + const fillRate = vendor.order_fill_rate ?? 0; + switch (filters.performance) { + case 'excellent': return fillRate >= 95; + case 'good': return fillRate >= 85 && fillRate < 95; + case 'fair': return fillRate >= 75 && fillRate < 85; + case 'poor': return fillRate < 75; + default: return true; + } + }); + } + + // Apply sorting + filtered.sort((a, b) => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + + if (typeof aVal === 'number' && typeof bVal === 'number') { + return sortDirection === 'asc' ? aVal - bVal : bVal - aVal; + } + + const aStr = String(aVal || ''); + const bStr = String(bVal || ''); + return sortDirection === 'asc' ? + aStr.localeCompare(bStr) : + bStr.localeCompare(aStr); + }); + + return filtered; + }, [data?.vendors, filters, sortColumn, sortDirection]); + + // Calculate pagination + const paginatedData = useMemo(() => { + const startIndex = (page - 1) * 50; + return filteredData.slice(startIndex, startIndex + 50); + }, [filteredData, page]); + + // Calculate stats from filtered data + const stats = useMemo(() => { + if (!filteredData.length) return data?.stats; + + const activeVendors = filteredData.filter(v => v.status === 'active').length; + const leadTimes = filteredData.map(v => v.avg_lead_time_days || 0).filter(lt => lt !== 0); + const fillRates = filteredData.map(v => v.order_fill_rate || 0).filter(fr => fr !== 0); + const onTimeRates = filteredData.map(v => v.on_time_delivery_rate || 0).filter(otr => otr !== 0); + + return { + totalVendors: filteredData.length, + activeVendors, + avgLeadTime: leadTimes.length ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0, + avgFillRate: fillRates.length ? fillRates.reduce((a, b) => a + b, 0) / fillRates.length : 0, + avgOnTimeDelivery: onTimeRates.length ? onTimeRates.reduce((a, b) => a + b, 0) / onTimeRates.length : 0 + }; + }, [filteredData, data?.stats]); + const handleSort = (column: keyof Vendor) => { setSortDirection(prev => { if (sortColumn !== column) return "asc"; @@ -75,23 +146,40 @@ export function Vendors() { }; return ( - -
+ +

Vendors

- {data?.pagination.total.toLocaleString() ?? "..."} vendors + {filteredData.length.toLocaleString()} vendors
-
+
-
+ Total Vendors -
{data?.stats?.totalVendors ?? "..."}
+
{stats?.totalVendors ?? "..."}

- {data?.stats?.activeVendors ?? "..."} active + {stats?.activeVendors ?? "..."} active

@@ -101,7 +189,7 @@ export function Vendors() { Avg Lead Time -
{typeof data?.stats?.avgLeadTime === 'number' ? data.stats.avgLeadTime.toFixed(1) : "..."} days
+
{typeof stats?.avgLeadTime === 'number' ? stats.avgLeadTime.toFixed(1) : "..."} days

Across all vendors

@@ -113,7 +201,7 @@ export function Vendors() { Fill Rate -
{typeof data?.stats?.avgFillRate === 'number' ? data.stats.avgFillRate.toFixed(1) : "..."}%
+
{typeof stats?.avgFillRate === 'number' ? stats.avgFillRate.toFixed(1) : "..."}%

Average order fill rate

@@ -125,13 +213,13 @@ export function Vendors() { On-Time Delivery -
{typeof data?.stats?.avgOnTimeDelivery === 'number' ? data.stats.avgOnTimeDelivery.toFixed(1) : "..."}%
+
{typeof stats?.avgOnTimeDelivery === 'number' ? stats.avgOnTimeDelivery.toFixed(1) : "..."}%

Average on-time rate

-
+
@@ -194,7 +282,7 @@ export function Vendors() { Loading vendors... - ) : data?.vendors?.map((vendor: Vendor) => ( + ) : paginatedData.map((vendor: Vendor) => ( {vendor.name} @@ -205,8 +293,10 @@ export function Vendors() { {typeof vendor.avg_lead_time_days === 'number' ? vendor.avg_lead_time_days.toFixed(1) : "0.0"} days {typeof vendor.on_time_delivery_rate === 'number' ? vendor.on_time_delivery_rate.toFixed(1) : "0.0"}% -
- {typeof vendor.order_fill_rate === 'number' ? vendor.order_fill_rate.toFixed(1) : "0.0"}% +
+
+ {typeof vendor.order_fill_rate === 'number' ? vendor.order_fill_rate.toFixed(1) : "0.0"}% +
{getPerformanceBadge(vendor.order_fill_rate ?? 0)}
@@ -214,7 +304,7 @@ export function Vendors() { {vendor.active_products?.toLocaleString() ?? 0} ))} - {!isLoading && !data?.vendors.length && ( + {!isLoading && !paginatedData.length && ( No vendors found @@ -225,8 +315,12 @@ export function Vendors() {
- {data?.pagination.pages > 1 && ( -
+ {filteredData.length > 0 && ( + @@ -234,22 +328,22 @@ export function Vendors() { href="#" onClick={(e) => { e.preventDefault(); - setPage(p => Math.max(1, p - 1)); + if (page > 1) setPage(p => p - 1); }} aria-disabled={page === 1} /> - {Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => ( - + {Array.from({ length: Math.ceil(filteredData.length / 50) }, (_, i) => ( + { e.preventDefault(); - setPage(p); + setPage(i + 1); }} + isActive={page === i + 1} > - {p} + {i + 1} ))} @@ -258,14 +352,14 @@ export function Vendors() { href="#" onClick={(e) => { e.preventDefault(); - setPage(p => Math.min(data.pagination.pages, p + 1)); + if (page < Math.ceil(filteredData.length / 50)) setPage(p => p + 1); }} - aria-disabled={page === data.pagination.pages} + aria-disabled={page >= Math.ceil(filteredData.length / 50)} /> -
+ )} );