diff --git a/inventory-server/src/routes/vendors.js b/inventory-server/src/routes/vendors.js index 2a40eae..c173e62 100644 --- a/inventory-server/src/routes/vendors.js +++ b/inventory-server/src/routes/vendors.js @@ -15,39 +15,43 @@ router.get('/', async (req, res) => { const sortDirection = req.query.sortDirection || 'asc'; // Build the WHERE clause based on filters - const whereConditions = []; + const whereConditions = ['p.vendor IS NOT NULL AND p.vendor != \'\'']; const params = []; if (search) { - whereConditions.push('(LOWER(p.vendor) LIKE LOWER(?) OR LOWER(vd.contact_name) LIKE LOWER(?))'); - params.push(`%${search}%`, `%${search}%`); + whereConditions.push('LOWER(p.vendor) LIKE LOWER(?)'); + params.push(`%${search}%`); } if (status !== 'all') { - whereConditions.push('vm.status = ?'); + whereConditions.push(` + CASE + WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 THEN 'active' + WHEN COALESCE(vm.total_orders, 0) > 0 THEN 'inactive' + ELSE 'pending' + END = ? + `); params.push(status); } if (performance !== 'all') { switch (performance) { case 'excellent': - whereConditions.push('vm.order_fill_rate >= 95'); + whereConditions.push('COALESCE(vm.order_fill_rate, 0) >= 95'); break; case 'good': - whereConditions.push('vm.order_fill_rate >= 85 AND vm.order_fill_rate < 95'); + whereConditions.push('COALESCE(vm.order_fill_rate, 0) >= 85 AND COALESCE(vm.order_fill_rate, 0) < 95'); break; case 'fair': - whereConditions.push('vm.order_fill_rate >= 75 AND vm.order_fill_rate < 85'); + whereConditions.push('COALESCE(vm.order_fill_rate, 0) >= 75 AND COALESCE(vm.order_fill_rate, 0) < 85'); break; case 'poor': - whereConditions.push('vm.order_fill_rate < 75'); + whereConditions.push('COALESCE(vm.order_fill_rate, 0) < 75'); break; } } - const whereClause = whereConditions.length > 0 - ? 'WHERE ' + whereConditions.join(' AND ') - : ''; + const whereClause = 'WHERE ' + whereConditions.join(' AND '); // Get total count for pagination const [countResult] = await pool.query(` @@ -59,57 +63,105 @@ router.get('/', async (req, res) => { // Get vendors with metrics const [vendors] = await pool.query(` - SELECT + SELECT DISTINCT p.vendor as name, - vd.contact_name, - vd.email, - vd.phone, - vm.status, - vm.avg_lead_time_days, - vm.on_time_delivery_rate, - vm.order_fill_rate, - vm.total_orders, - COUNT(DISTINCT p.product_id) as active_products + COALESCE(vm.active_products, 0) as active_products, + COALESCE(vm.total_orders, 0) as total_orders, + COALESCE(vm.avg_lead_time_days, 0) as avg_lead_time_days, + COALESCE(vm.on_time_delivery_rate, 0) as on_time_delivery_rate, + COALESCE(vm.order_fill_rate, 0) as order_fill_rate, + CASE + WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 THEN 'active' + WHEN COALESCE(vm.total_orders, 0) > 0 THEN 'inactive' + ELSE 'pending' + END as status FROM products p LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor - LEFT JOIN vendor_details vd ON p.vendor = vd.vendor ${whereClause} - GROUP BY p.vendor + AND p.vendor IS NOT NULL AND p.vendor != '' ORDER BY ${sortColumn} ${sortDirection} LIMIT ? OFFSET ? `, [...params, limit, offset]); + // Get cost metrics for these vendors + const vendorNames = vendors.map(v => v.name); + const [costMetrics] = await pool.query(` + SELECT + vendor, + ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost, + SUM(ordered * cost_price) as total_spend + FROM purchase_orders + WHERE status = 'closed' + AND cost_price IS NOT NULL + AND ordered > 0 + AND vendor IN (?) + GROUP BY vendor + `, [vendorNames]); + + // Create a map of cost metrics by vendor + const costMetricsMap = costMetrics.reduce((acc, curr) => { + acc[curr.vendor] = { + avg_unit_cost: curr.avg_unit_cost, + total_spend: curr.total_spend + }; + return acc; + }, {}); + // Get overall stats const [stats] = await pool.query(` SELECT COUNT(DISTINCT p.vendor) as totalVendors, - COUNT(DISTINCT CASE WHEN vm.status = 'active' THEN p.vendor END) as activeVendors, - COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1), 0) as avgLeadTime, - COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1), 0) as avgFillRate, - COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1), 0) as avgOnTimeDelivery + COUNT(DISTINCT CASE + WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 + THEN p.vendor + END) as activeVendors, + ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1) as avgLeadTime, + ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1) as avgFillRate, + ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1) as avgOnTimeDelivery FROM products p LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor + WHERE p.vendor IS NOT NULL AND p.vendor != '' + `); + + // Get overall cost metrics + const [overallCostMetrics] = await pool.query(` + SELECT + ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost, + SUM(ordered * cost_price) as total_spend + FROM purchase_orders + WHERE status = 'closed' + AND cost_price IS NOT NULL + AND ordered > 0 + AND vendor IS NOT NULL AND vendor != '' `); res.json({ vendors: vendors.map(vendor => ({ - ...vendor, + vendor_id: vendor.vendor_id || vendor.name, + name: vendor.name, + status: vendor.status, avg_lead_time_days: parseFloat(vendor.avg_lead_time_days || 0), on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate || 0), order_fill_rate: parseFloat(vendor.order_fill_rate || 0), total_orders: parseInt(vendor.total_orders || 0), - active_products: parseInt(vendor.active_products || 0) + active_products: parseInt(vendor.active_products || 0), + avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0), + total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0) })), stats: { - ...stats[0], + totalVendors: parseInt(stats[0].totalVendors || 0), + activeVendors: parseInt(stats[0].activeVendors || 0), avgLeadTime: parseFloat(stats[0].avgLeadTime || 0), avgFillRate: parseFloat(stats[0].avgFillRate || 0), - avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0) + avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0), + avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost || 0), + totalSpend: parseFloat(overallCostMetrics[0].total_spend || 0) }, pagination: { - total: countResult[0].total, - pages: Math.ceil(countResult[0].total / limit), - current: page, + total: parseInt(countResult[0].total || 0), + currentPage: page, + pages: Math.ceil(parseInt(countResult[0].total || 0) / limit), + limit } }); } catch (error) { diff --git a/inventory/src/pages/Categories.tsx b/inventory/src/pages/Categories.tsx index 1d85cbb..c4b9ef2 100644 --- a/inventory/src/pages/Categories.tsx +++ b/inventory/src/pages/Categories.tsx @@ -37,7 +37,7 @@ export function Categories() { parent: "all", performance: "all", }); - const [sorting, setSorting] = useState({ + const [] = useState({ column: 'name', direction: 'asc' }); diff --git a/inventory/src/pages/PurchaseOrders.tsx b/inventory/src/pages/PurchaseOrders.tsx index cb42db4..88400a8 100644 --- a/inventory/src/pages/PurchaseOrders.tsx +++ b/inventory/src/pages/PurchaseOrders.tsx @@ -87,7 +87,7 @@ interface PurchaseOrdersResponse { export default function PurchaseOrders() { const [purchaseOrders, setPurchaseOrders] = useState([]); - const [vendorMetrics, setVendorMetrics] = useState([]); + const [, setVendorMetrics] = useState([]); const [costAnalysis, setCostAnalysis] = useState(null); const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(true); @@ -391,39 +391,6 @@ export default function PurchaseOrders() { )} - {/* Vendor Performance */} - - - Vendor Performance - - - - - - Vendor - Total Orders - Avg Delivery Days - Fulfillment Rate - Avg Unit Cost - Total Spend - - - - {vendorMetrics.map((vendor) => ( - - {vendor.vendor_name} - {vendor.total_orders.toLocaleString()} - {vendor.avg_delivery_days?.toFixed(1) || 'N/A'} - {formatPercent(vendor.fulfillment_rate)} - ${formatNumber(vendor.avg_unit_cost)} - ${formatNumber(vendor.total_spend)} - - ))} - -
-
-
- {/* Cost Analysis */} diff --git a/inventory/src/pages/Vendors.tsx b/inventory/src/pages/Vendors.tsx index c671526..a718694 100644 --- a/inventory/src/pages/Vendors.tsx +++ b/inventory/src/pages/Vendors.tsx @@ -12,15 +12,14 @@ import config from "../config"; interface Vendor { vendor_id: number; name: string; - contact_name: string; - email: string; - phone: string; status: string; avg_lead_time_days: number; on_time_delivery_rate: number; order_fill_rate: number; total_orders: number; active_products: number; + avg_unit_cost: number; + total_spend: number; } interface VendorFilters { @@ -38,15 +37,26 @@ export function Vendors() { status: "all", performance: "all", }); - const [sorting, setSorting] = useState({ + const [] = useState({ column: 'name', direction: 'asc' }); const { data, isLoading } = useQuery({ - queryKey: ["vendors"], + queryKey: ["vendors", page, filters, sortColumn, sortDirection], queryFn: async () => { - const response = await fetch(`${config.apiUrl}/vendors`); + const params = new URLSearchParams({ + page: page.toString(), + limit: '50', + search: filters.search, + status: filters.status, + performance: filters.performance, + sortColumn, + sortDirection + }); + const response = await fetch(`${config.apiUrl}/vendors?${params}`, { + credentials: 'include' + }); if (!response.ok) throw new Error("Failed to fetch vendors"); return response.json(); }, @@ -62,9 +72,7 @@ export function Vendors() { 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) + vendor.name.toLowerCase().includes(searchLower) ); } @@ -108,27 +116,15 @@ export function Vendors() { // Calculate pagination const paginatedData = useMemo(() => { - const startIndex = (page - 1) * 50; - return filteredData.slice(startIndex, startIndex + 50); - }, [filteredData, page]); + if (!data?.vendors) return []; + return data.vendors; + }, [data?.vendors]); // 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]); + if (!data?.stats) return null; + return data.stats; + }, [data?.stats]); const handleSort = (column: keyof Vendor) => { setSortDirection(prev => { @@ -186,36 +182,38 @@ export function Vendors() { - Avg Lead Time + Total Spend -
{typeof stats?.avgLeadTime === 'number' ? stats.avgLeadTime.toFixed(1) : "..."} days
+
+ ${typeof stats?.totalSpend === 'number' ? stats.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "..."} +

- Across all vendors + Avg unit cost: ${typeof stats?.avgUnitCost === 'number' ? stats.avgUnitCost.toFixed(2) : "..."}

- Fill Rate + Performance
{typeof stats?.avgFillRate === 'number' ? stats.avgFillRate.toFixed(1) : "..."}%

- Average order fill rate + Fill rate / {typeof stats?.avgOnTimeDelivery === 'number' ? stats.avgOnTimeDelivery.toFixed(1) : "..."}% on-time

- On-Time Delivery + Lead Time -
{typeof stats?.avgOnTimeDelivery === 'number' ? stats.avgOnTimeDelivery.toFixed(1) : "..."}%
+
{typeof stats?.avgLeadTime === 'number' ? stats.avgLeadTime.toFixed(1) : "..."} days

- Average on-time rate + Average delivery time

@@ -265,12 +263,13 @@ export function Vendors() { - handleSort("name")} className="cursor-pointer">Name - handleSort("contact_name")} className="cursor-pointer">Contact + handleSort("name")} className="cursor-pointer">Vendor handleSort("status")} className="cursor-pointer">Status handleSort("avg_lead_time_days")} className="cursor-pointer">Lead Time - handleSort("on_time_delivery_rate")} className="cursor-pointer">On-Time Rate + handleSort("on_time_delivery_rate")} className="cursor-pointer">On-Time % handleSort("order_fill_rate")} className="cursor-pointer">Fill Rate + handleSort("avg_unit_cost")} className="cursor-pointer">Avg Unit Cost + handleSort("total_spend")} className="cursor-pointer">Total Spend handleSort("total_orders")} className="cursor-pointer">Orders handleSort("active_products")} className="cursor-pointer">Products @@ -278,17 +277,13 @@ export function Vendors() { {isLoading ? ( - + Loading vendors... ) : paginatedData.map((vendor: Vendor) => ( {vendor.name} - -
{vendor.contact_name}
-
{vendor.email}
-
{vendor.status} {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"}% @@ -300,13 +295,15 @@ export function Vendors() { {getPerformanceBadge(vendor.order_fill_rate ?? 0)} + ${typeof vendor.avg_unit_cost === 'number' ? vendor.avg_unit_cost.toFixed(2) : "0.00"} + ${typeof vendor.total_spend === 'number' ? vendor.total_spend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "0"} {vendor.total_orders?.toLocaleString() ?? 0} {vendor.active_products?.toLocaleString() ?? 0}
))} {!isLoading && !paginatedData.length && ( - + No vendors found @@ -315,7 +312,7 @@ export function Vendors() {
- {filteredData.length > 0 && ( + {data?.pagination && data.pagination.total > 0 && ( - {Array.from({ length: Math.ceil(filteredData.length / 50) }, (_, i) => ( + {Array.from({ length: data.pagination.pages }, (_, i) => ( { e.preventDefault(); - if (page < Math.ceil(filteredData.length / 50)) setPage(p => p + 1); + if (page < data.pagination.pages) setPage(p => p + 1); }} - aria-disabled={page >= Math.ceil(filteredData.length / 50)} + aria-disabled={page >= data.pagination.pages} />