From ac14179bd29121408c684f062d3f6f74cf965988 Mon Sep 17 00:00:00 2001 From: Matt Date: Sat, 12 Apr 2025 10:54:42 -0400 Subject: [PATCH] PO-related fixes --- inventory-server/scripts/import/products.js | 7 +- .../scripts/import/purchase-orders.js | 94 +++++- inventory-server/src/routes/dashboard.js | 12 + .../src/routes/purchase-orders.js | 130 ++++++-- inventory/src/pages/PurchaseOrders.tsx | 302 ++++++++++++++---- 5 files changed, 433 insertions(+), 112 deletions(-) diff --git a/inventory-server/scripts/import/products.js b/inventory-server/scripts/import/products.js index cc0a098..f1c6a2f 100644 --- a/inventory-server/scripts/import/products.js +++ b/inventory-server/scripts/import/products.js @@ -406,12 +406,7 @@ async function materializeCalculations(prodConnection, localConnection, incremen WHERE oi.prod_pid = p.pid AND o.order_status >= 20) AS total_sold, pls.date_sold as date_last_sold, (SELECT iid FROM product_images WHERE pid = p.pid AND \`order\` = 255 LIMIT 1) AS primary_iid, - GROUP_CONCAT(DISTINCT CASE - WHEN pc.cat_id IS NOT NULL - AND pc.type IN (10, 20, 11, 21, 12, 13) - AND pci.cat_id NOT IN (16, 17) - THEN pci.cat_id - END) as category_ids + NULL as category_ids FROM products p LEFT JOIN shop_inventory si ON p.pid = si.pid AND si.store = 0 LEFT JOIN current_inventory ci ON p.pid = ci.pid diff --git a/inventory-server/scripts/import/purchase-orders.js b/inventory-server/scripts/import/purchase-orders.js index 1fe7563..e0a58f4 100644 --- a/inventory-server/scripts/import/purchase-orders.js +++ b/inventory-server/scripts/import/purchase-orders.js @@ -70,6 +70,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental DROP TABLE IF EXISTS temp_receivings; DROP TABLE IF EXISTS temp_receiving_allocations; DROP TABLE IF EXISTS employee_names; + DROP TABLE IF EXISTS temp_supplier_names; -- Temporary table for purchase orders CREATE TEMP TABLE temp_purchase_orders ( @@ -103,6 +104,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental receiving_created_date TIMESTAMP WITH TIME ZONE, supplier_id INTEGER, status TEXT, + sku TEXT, + name TEXT, PRIMARY KEY (receiving_id, pid) ); @@ -421,9 +424,12 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental rp.cost_each, rp.received_by, rp.received_date, - r.date_created as receiving_created_date + r.date_created as receiving_created_date, + COALESCE(p.itemnumber, 'NO-SKU') AS sku, + COALESCE(p.description, 'Unknown Product') AS name FROM receivings_products rp JOIN receivings r ON rp.receiving_id = r.receiving_id + LEFT JOIN products p ON rp.pid = p.pid WHERE rp.receiving_id IN (?) `, [receivingIds]); @@ -443,7 +449,9 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental received_date: validateDate(product.received_date) || validateDate(product.receiving_created_date), receiving_created_date: validateDate(product.receiving_created_date), supplier_id: receiving.supplier_id, - status: receivingStatusMap[receiving.status] || 'created' + status: receivingStatusMap[receiving.status] || 'created', + sku: product.sku, + name: product.name }); } @@ -452,8 +460,8 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental const batch = completeReceivings.slice(i, i + INSERT_BATCH_SIZE); const placeholders = batch.map((_, idx) => { - const base = idx * 10; - return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10})`; + const base = idx * 12; + return `($${base + 1}, $${base + 2}, $${base + 3}, $${base + 4}, $${base + 5}, $${base + 6}, $${base + 7}, $${base + 8}, $${base + 9}, $${base + 10}, $${base + 11}, $${base + 12})`; }).join(','); const values = batch.flatMap(r => [ @@ -466,13 +474,16 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental r.received_date, r.receiving_created_date, r.supplier_id, - r.status + r.status, + r.sku, + r.name ]); await localConnection.query(` INSERT INTO temp_receivings ( receiving_id, po_id, pid, qty_each, cost_each, received_by, - received_date, receiving_created_date, supplier_id, status + received_date, receiving_created_date, supplier_id, status, + sku, name ) VALUES ${placeholders} ON CONFLICT (receiving_id, pid) DO UPDATE SET @@ -483,7 +494,9 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental received_date = EXCLUDED.received_date, receiving_created_date = EXCLUDED.receiving_created_date, supplier_id = EXCLUDED.supplier_id, - status = EXCLUDED.status + status = EXCLUDED.status, + sku = EXCLUDED.sku, + name = EXCLUDED.name `, values); } @@ -506,6 +519,55 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental } } + // Add this section before the FIFO steps to create a supplier names mapping + outputProgress({ + status: "running", + operation: "Purchase orders import", + message: "Fetching supplier data for vendor mapping" + }); + + // Fetch supplier data from production and store in a temp table + const [suppliers] = await prodConnection.query(` + SELECT + supplierid, + companyname + FROM suppliers + WHERE companyname IS NOT NULL AND companyname != '' + `); + + if (suppliers.length > 0) { + // Create temp table for supplier names + await localConnection.query(` + DROP TABLE IF EXISTS temp_supplier_names; + CREATE TEMP TABLE temp_supplier_names ( + supplier_id INTEGER PRIMARY KEY, + company_name TEXT NOT NULL + ); + `); + + // Insert supplier data in batches + for (let i = 0; i < suppliers.length; i += INSERT_BATCH_SIZE) { + const batch = suppliers.slice(i, i + INSERT_BATCH_SIZE); + + const placeholders = batch.map((_, idx) => { + const base = idx * 2; + return `($${base + 1}, $${base + 2})`; + }).join(','); + + const values = batch.flatMap(s => [ + s.supplierid, + s.companyname || 'Unnamed Supplier' + ]); + + await localConnection.query(` + INSERT INTO temp_supplier_names (supplier_id, company_name) + VALUES ${placeholders} + ON CONFLICT (supplier_id) DO UPDATE SET + company_name = EXCLUDED.company_name + `, values); + } + } + // 3. Implement FIFO allocation of receivings to purchase orders outputProgress({ status: "running", @@ -583,12 +645,20 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental SELECT r.receiving_id::text as po_id, r.pid, - COALESCE(p.sku, 'NO-SKU') as sku, - COALESCE(p.name, 'Unknown Product') as name, + r.sku, + r.name, COALESCE( + -- First, check if we already have a vendor name from the temp_purchase_orders table (SELECT vendor FROM temp_purchase_orders WHERE supplier_id = r.supplier_id LIMIT 1), - 'Unknown Vendor' + -- Next, check the supplier_names mapping table we created + (SELECT company_name FROM temp_supplier_names + WHERE supplier_id = r.supplier_id), + -- If both fail, use a generic name with the supplier ID + CASE + WHEN r.supplier_id IS NOT NULL THEN 'Supplier #' || r.supplier_id::text + ELSE 'Unknown Supplier' + END ) as vendor, COALESCE(r.received_date, r.receiving_created_date) as date, 'created' as status, @@ -598,9 +668,6 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental COALESCE(r.receiving_created_date, r.received_date) as date_created, NULL as date_ordered FROM temp_receivings r - LEFT JOIN ( - SELECT DISTINCT pid, sku, name FROM temp_purchase_orders - ) p ON r.pid = p.pid WHERE r.po_id IS NULL OR NOT EXISTS ( SELECT 1 FROM temp_purchase_orders po @@ -923,6 +990,7 @@ async function importPurchaseOrders(prodConnection, localConnection, incremental DROP TABLE IF EXISTS temp_receivings; DROP TABLE IF EXISTS temp_receiving_allocations; DROP TABLE IF EXISTS employee_names; + DROP TABLE IF EXISTS temp_supplier_names; `); // Commit transaction diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index 1c1cec5..de6317a 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -111,25 +111,35 @@ router.get('/purchase/metrics', async (req, res) => { SELECT COALESCE(COUNT(DISTINCT CASE WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9) THEN po.po_id END), 0)::integer as active_pos, COALESCE(COUNT(DISTINCT CASE WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9) AND po.expected_date < CURRENT_DATE THEN po.po_id END), 0)::integer as overdue_pos, COALESCE(SUM(CASE WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9) THEN po.ordered ELSE 0 END), 0)::integer as total_units, ROUND(COALESCE(SUM(CASE WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9) THEN po.ordered * po.cost_price ELSE 0 END), 0)::numeric, 3) as total_cost, ROUND(COALESCE(SUM(CASE WHEN po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9) THEN po.ordered * pm.current_price ELSE 0 END), 0)::numeric, 3) as total_retail @@ -147,6 +157,8 @@ router.get('/purchase/metrics', async (req, res) => { FROM purchase_orders po JOIN product_metrics pm ON po.pid = pm.pid WHERE po.receiving_status NOT IN ('partial_received', 'full_received', 'paid') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + AND NOT (po.date < CURRENT_DATE - INTERVAL '1 month' AND po.received >= po.ordered * 0.9) GROUP BY po.vendor HAVING ROUND(COALESCE(SUM(po.ordered * po.cost_price), 0)::numeric, 3) > 0 ORDER BY cost DESC diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js index 740efad..50cdb0c 100644 --- a/inventory-server/src/routes/purchase-orders.js +++ b/inventory-server/src/routes/purchase-orders.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); // Status code constants +// Frontend uses these numeric codes but database uses strings const STATUS = { CANCELED: 0, CREATED: 1, @@ -13,6 +14,18 @@ const STATUS = { DONE: 50 }; +// Status mapping from database string values to frontend numeric codes +const STATUS_MAPPING = { + 'canceled': STATUS.CANCELED, + 'created': STATUS.CREATED, + 'electronically_ready_send': STATUS.ELECTRONICALLY_READY_SEND, + 'ordered': STATUS.ORDERED, + 'preordered': STATUS.PREORDERED, + 'electronically_sent': STATUS.ELECTRONICALLY_SENT, + 'receiving_started': STATUS.RECEIVING_STARTED, + 'done': STATUS.DONE +}; + const RECEIVING_STATUS = { CANCELED: 0, CREATED: 1, @@ -21,6 +34,26 @@ const RECEIVING_STATUS = { PAID: 50 }; +// Receiving status mapping from database string values to frontend numeric codes +const RECEIVING_STATUS_MAPPING = { + 'canceled': RECEIVING_STATUS.CANCELED, + 'created': RECEIVING_STATUS.CREATED, + 'partial_received': RECEIVING_STATUS.PARTIAL_RECEIVED, + 'full_received': RECEIVING_STATUS.FULL_RECEIVED, + 'paid': RECEIVING_STATUS.PAID +}; + +// Helper for SQL status value comparison with string values in DB +function getStatusWhereClause(statusNum) { + const dbStatuses = Object.keys(STATUS_MAPPING).filter(key => + STATUS_MAPPING[key] === parseInt(statusNum)); + + if (dbStatuses.length > 0) { + return `po.status = '${dbStatuses[0]}'`; + } + return `1=0`; // No match found, return false condition +} + // Get all purchase orders with summary metrics router.get('/', async (req, res) => { try { @@ -38,9 +71,7 @@ router.get('/', async (req, res) => { } if (status && status !== 'all') { - whereClause += ` AND po.status = $${paramCounter}`; - params.push(Number(status)); - paramCounter++; + whereClause += ` AND ${getStatusWhereClause(status)}`; } if (vendor && vendor !== 'all') { @@ -139,17 +170,17 @@ router.get('/', async (req, res) => { GROUP BY po_id, vendor, date, status, receiving_status ) SELECT - po_id as id, - vendor as vendor_name, - to_char(date, 'YYYY-MM-DD') as order_date, - status, - receiving_status, - total_items, - total_quantity, - total_cost, - total_received, - fulfillment_rate - FROM po_totals + pt.po_id as id, + pt.vendor as vendor_name, + to_char(pt.date, 'YYYY-MM-DD') as order_date, + pt.status, + pt.receiving_status, + pt.total_items, + pt.total_quantity, + pt.total_cost, + pt.total_received, + pt.fulfillment_rate + FROM po_totals pt ORDER BY ${orderByClause} LIMIT $${paramCounter} OFFSET $${paramCounter + 1} `, [...params, Number(limit), offset]); @@ -170,13 +201,36 @@ router.get('/', async (req, res) => { ORDER BY status `); - // Parse numeric values + // Get product vendors for orders with Unknown Vendor + const poIds = orders.filter(o => o.vendor_name === 'Unknown Vendor').map(o => o.id); + let vendorMappings = {}; + + if (poIds.length > 0) { + const { rows: productVendors } = await pool.query(` + SELECT DISTINCT po.po_id, p.vendor + FROM purchase_orders po + JOIN products p ON po.pid = p.pid + WHERE po.po_id = ANY($1) + AND p.vendor IS NOT NULL AND p.vendor != '' + GROUP BY po.po_id, p.vendor + `, [poIds]); + + // Create mapping of PO ID to actual vendor from products table + vendorMappings = productVendors.reduce((acc, pv) => { + if (!acc[pv.po_id]) { + acc[pv.po_id] = pv.vendor; + } + return acc; + }, {}); + } + + // Parse numeric values and map status strings to numeric codes const parsedOrders = orders.map(order => ({ id: order.id, - vendor_name: order.vendor_name, + vendor_name: vendorMappings[order.id] || order.vendor_name, order_date: order.order_date, - status: Number(order.status), - receiving_status: Number(order.receiving_status), + status: STATUS_MAPPING[order.status] || 0, // Map string status to numeric code + receiving_status: RECEIVING_STATUS_MAPPING[order.receiving_status] || 0, // Map string receiving status to numeric code total_items: Number(order.total_items) || 0, total_quantity: Number(order.total_quantity) || 0, total_cost: Number(order.total_cost) || 0, @@ -205,7 +259,7 @@ router.get('/', async (req, res) => { }, filters: { vendors: vendors.map(v => v.vendor), - statuses: statuses.map(s => Number(s.status)) + statuses: statuses.map(s => STATUS_MAPPING[s.status] || 0) // Map string statuses to numeric codes for the frontend } }); } catch (error) { @@ -228,14 +282,15 @@ router.get('/vendor-metrics', async (req, res) => { received, cost_price, CASE - WHEN status >= ${STATUS.RECEIVING_STARTED} AND receiving_status >= ${RECEIVING_STATUS.PARTIAL_RECEIVED} + WHEN status IN ('receiving_started', 'done') + AND receiving_status IN ('partial_received', 'full_received', 'paid') AND received_date IS NOT NULL AND date IS NOT NULL THEN (received_date - date)::integer ELSE NULL END as delivery_days FROM purchase_orders WHERE vendor IS NOT NULL AND vendor != '' - AND status != ${STATUS.CANCELED} -- Exclude canceled orders + AND status != 'canceled' -- Exclude canceled orders ) SELECT vendor as vendor_name, @@ -296,7 +351,7 @@ router.get('/cost-analysis', async (req, res) => { FROM purchase_orders po JOIN product_categories pc ON po.pid = pc.pid JOIN categories c ON pc.cat_id = c.cat_id - WHERE po.status != ${STATUS.CANCELED} -- Exclude canceled orders + WHERE po.status != 'canceled' -- Exclude canceled orders ) SELECT category, @@ -311,7 +366,7 @@ router.get('/cost-analysis', async (req, res) => { ORDER BY total_spend DESC `); - // Parse numeric values + // Parse numeric values and include ALL data for each category const parsedAnalysis = { unique_products: 0, avg_cost: 0, @@ -320,6 +375,11 @@ router.get('/cost-analysis', async (req, res) => { cost_variance: 0, total_spend_by_category: analysis.map(cat => ({ category: cat.category, + unique_products: Number(cat.unique_products) || 0, + avg_cost: Number(cat.avg_cost) || 0, + min_cost: Number(cat.min_cost) || 0, + max_cost: Number(cat.max_cost) || 0, + cost_variance: Number(cat.cost_variance) || 0, total_spend: Number(cat.total_spend) || 0 })) }; @@ -366,7 +426,7 @@ router.get('/receiving-status', async (req, res) => { SUM(received) as total_received, ROUND(SUM(ordered * cost_price)::numeric, 3) as total_cost FROM purchase_orders - WHERE status != ${STATUS.CANCELED} + WHERE status != 'canceled' GROUP BY po_id, status, receiving_status ) SELECT @@ -379,16 +439,16 @@ router.get('/receiving-status', async (req, res) => { ROUND(SUM(total_cost)::numeric, 3) as total_value, ROUND(AVG(total_cost)::numeric, 3) as avg_cost, COUNT(DISTINCT CASE - WHEN receiving_status = ${RECEIVING_STATUS.CREATED} THEN po_id + WHEN receiving_status = 'created' THEN po_id END) as pending_count, COUNT(DISTINCT CASE - WHEN receiving_status = ${RECEIVING_STATUS.PARTIAL_RECEIVED} THEN po_id + WHEN receiving_status = 'partial_received' THEN po_id END) as partial_count, COUNT(DISTINCT CASE - WHEN receiving_status >= ${RECEIVING_STATUS.FULL_RECEIVED} THEN po_id + WHEN receiving_status IN ('full_received', 'paid') THEN po_id END) as completed_count, COUNT(DISTINCT CASE - WHEN receiving_status = ${RECEIVING_STATUS.CANCELED} THEN po_id + WHEN receiving_status = 'canceled' THEN po_id END) as canceled_count FROM po_totals `); @@ -423,7 +483,7 @@ router.get('/order-vs-received', async (req, res) => { const { rows: quantities } = await pool.query(` SELECT - p.product_id, + p.pid as product_id, p.title as product, p.SKU as sku, SUM(po.ordered) as ordered_quantity, @@ -433,9 +493,9 @@ router.get('/order-vs-received', async (req, res) => { ) as fulfillment_rate, COUNT(DISTINCT po.po_id) as order_count FROM products p - JOIN purchase_orders po ON p.product_id = po.product_id + JOIN purchase_orders po ON p.pid = po.pid WHERE po.date >= (CURRENT_DATE - INTERVAL '90 days') - GROUP BY p.product_id, p.title, p.SKU + GROUP BY p.pid, p.title, p.SKU HAVING COUNT(DISTINCT po.po_id) > 0 ORDER BY SUM(po.ordered) DESC LIMIT 20 @@ -445,10 +505,10 @@ router.get('/order-vs-received', async (req, res) => { const parsedQuantities = quantities.map(q => ({ id: q.product_id, ...q, - ordered_quantity: Number(q.ordered_quantity), - received_quantity: Number(q.received_quantity), - fulfillment_rate: Number(q.fulfillment_rate), - order_count: Number(q.order_count) + ordered_quantity: Number(q.ordered_quantity) || 0, + received_quantity: Number(q.received_quantity) || 0, + fulfillment_rate: Number(q.fulfillment_rate) || 0, + order_count: Number(q.order_count) || 0 })); res.json(parsedQuantities); diff --git a/inventory/src/pages/PurchaseOrders.tsx b/inventory/src/pages/PurchaseOrders.tsx index 35b077d..bbdf5be 100644 --- a/inventory/src/pages/PurchaseOrders.tsx +++ b/inventory/src/pages/PurchaseOrders.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table'; -import { Loader2, ArrowUpDown } from 'lucide-react'; +import { Loader2, ArrowUpDown, Info, BarChart3 } from 'lucide-react'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; import { Badge } from '../components/ui/badge'; @@ -15,10 +15,26 @@ import { import { Pagination, PaginationContent, + PaginationEllipsis, PaginationItem, + PaginationLink, PaginationNext, PaginationPrevious, } from '../components/ui/pagination'; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '../components/ui/tooltip'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../components/ui/dialog"; import { motion } from 'motion/react'; import { PurchaseOrderStatus, @@ -29,7 +45,7 @@ import { } from '../types/status-codes'; interface PurchaseOrder { - id: number; + id: number | string; vendor_name: string; order_date: string; status: number; @@ -59,6 +75,9 @@ interface CostAnalysis { total_spend_by_category: { category: string; total_spend: number; + unique_products?: number; + avg_cost?: number; + cost_variance?: number; }[]; } @@ -89,7 +108,7 @@ interface PurchaseOrdersResponse { }; filters: { vendors: string[]; - statuses: string[]; + statuses: number[]; }; } @@ -109,7 +128,7 @@ export default function PurchaseOrders() { }); const [filterOptions, setFilterOptions] = useState<{ vendors: string[]; - statuses: string[]; + statuses: number[]; }>({ vendors: [], statuses: [] @@ -120,6 +139,7 @@ export default function PurchaseOrders() { page: 1, limit: 100, }); + const [costAnalysisOpen, setCostAnalysisOpen] = useState(false); const STATUS_FILTER_OPTIONS = [ { value: 'all', label: 'All Statuses' }, @@ -133,14 +153,15 @@ export default function PurchaseOrders() { const fetchData = async () => { try { + setLoading(true); const searchParams = new URLSearchParams({ page: page.toString(), limit: '100', sortColumn, sortDirection, ...filters.search && { search: filters.search }, - ...filters.status && { status: filters.status }, - ...filters.vendor && { vendor: filters.vendor }, + ...filters.status !== 'all' && { status: filters.status }, + ...filters.vendor !== 'all' && { vendor: filters.vendor }, }); const [ @@ -205,7 +226,23 @@ export default function PurchaseOrders() { console.error('Failed to fetch cost analysis:', await costAnalysisRes.text()); } - setPurchaseOrders(purchaseOrdersData.orders); + // Process orders data + const processedOrders = purchaseOrdersData.orders.map(order => { + let processedOrder = { + ...order, + status: Number(order.status), + receiving_status: Number(order.receiving_status), + total_items: Number(order.total_items) || 0, + total_quantity: Number(order.total_quantity) || 0, + total_cost: Number(order.total_cost) || 0, + total_received: Number(order.total_received) || 0, + fulfillment_rate: Number(order.fulfillment_rate) || 0 + }; + + return processedOrder; + }); + + setPurchaseOrders(processedOrders); setPagination(purchaseOrdersData.pagination); setFilterOptions(purchaseOrdersData.filters); setSummary(purchaseOrdersData.summary); @@ -280,6 +317,10 @@ export default function PurchaseOrders() { }); }; + const formatCurrency = (value: number) => { + return `$${formatNumber(value)}`; + }; + const formatPercent = (value: number) => { return (value * 100).toLocaleString('en-US', { minimumFractionDigits: 1, @@ -287,6 +328,141 @@ export default function PurchaseOrders() { }) + '%'; }; + // Generate pagination items + const getPaginationItems = () => { + const items = []; + const maxPagesToShow = 5; + const totalPages = pagination.pages; + + // Always show first page + if (totalPages > 0) { + items.push( + + page !== 1 && setPage(1)} + > + 1 + + + ); + } + + // Add ellipsis if needed + if (page > 3) { + items.push( + + + + ); + } + + // Add pages around current page + const startPage = Math.max(2, page - 1); + const endPage = Math.min(totalPages - 1, page + 1); + + for (let i = startPage; i <= endPage; i++) { + if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately + items.push( + + page !== i && setPage(i)} + > + {i} + + + ); + } + + // Add ellipsis if needed + if (page < totalPages - 2) { + items.push( + + + + ); + } + + // Always show last page if there are multiple pages + if (totalPages > 1) { + items.push( + + page !== totalPages && setPage(totalPages)} + > + {totalPages} + + + ); + } + + return items; + }; + + // Cost Analysis table component + const CostAnalysisTable = () => { + if (!costAnalysis) return null; + + return ( + + + + Category + Products + Avg. Cost + Price Variance + Total Spend + % of Total + + + + {costAnalysis?.total_spend_by_category?.length ? + costAnalysis.total_spend_by_category.map((category) => { + // Calculate percentage of total spend + const totalSpendPercentage = + costAnalysis.total_spend_by_category.reduce((sum, cat) => sum + cat.total_spend, 0) > 0 + ? (category.total_spend / + costAnalysis.total_spend_by_category.reduce((sum, cat) => sum + cat.total_spend, 0)) + : 0; + + return ( + + + {category.category || 'Uncategorized'} + + + {category.unique_products?.toLocaleString() || "N/A"} + + + {category.avg_cost !== undefined ? formatCurrency(category.avg_cost) : "N/A"} + + + {category.cost_variance !== undefined ? + parseFloat(category.cost_variance.toFixed(2)).toLocaleString() : "N/A"} + + + {formatCurrency(category.total_spend)} + + + {formatPercent(totalSpendPercentage)} + + + ); + }) : ( + + + No cost analysis data available + + + ) + } + +
+ ); + }; + if (loading) { return (
@@ -315,7 +491,7 @@ export default function PurchaseOrders() {
- ${formatNumber(summary?.total_value || 0)} + {formatCurrency(summary?.total_value || 0)}
@@ -331,12 +507,44 @@ export default function PurchaseOrders() { - Avg Cost per PO + Spending Analysis + + + + + + + + + Purchase Order Spending Analysis by Category + + + This analysis shows spending distribution across product categories + + +
+ +
+
+
- ${formatNumber(summary?.avg_cost || 0)} + {formatCurrency(summary?.avg_cost || 0)} +
+ Avg. Cost per PO +
+
@@ -435,9 +643,11 @@ export default function PurchaseOrders() { {getStatusBadge(po.status, po.receiving_status)} {po.total_items.toLocaleString()} {po.total_quantity.toLocaleString()} - ${formatNumber(po.total_cost)} + {formatCurrency(po.total_cost)} {po.total_received.toLocaleString()} - {formatPercent(po.fulfillment_rate)} + + {po.fulfillment_rate === null ? 'N/A' : formatPercent(po.fulfillment_rate)} + ))} {!purchaseOrders.length && ( @@ -454,62 +664,38 @@ export default function PurchaseOrders() { {/* Pagination */} {pagination.pages > 1 && ( -
+
- + { + e.preventDefault(); + if (page > 1) setPage(page - 1); + }} + aria-disabled={page === 1} + className={page === 1 ? "pointer-events-none opacity-50" : ""} + /> + + {getPaginationItems()} + - + { + e.preventDefault(); + if (page < pagination.pages) setPage(page + 1); + }} + aria-disabled={page === pagination.pages} + className={page === pagination.pages ? "pointer-events-none opacity-50" : ""} + />
)} - - {/* Cost Analysis */} - - - Cost Analysis by Category - - - - - - Category - Total Spend - - - - {costAnalysis?.total_spend_by_category?.map((category) => ( - - {category.category} - ${formatNumber(category.total_spend)} - - )) || ( - - - No cost analysis data available - - - )} - -
-
-
); } \ No newline at end of file