diff --git a/inventory-server/src/routes/dashboard.js b/inventory-server/src/routes/dashboard.js index ce25e68..3a21c19 100644 --- a/inventory-server/src/routes/dashboard.js +++ b/inventory-server/src/routes/dashboard.js @@ -108,53 +108,52 @@ router.get('/purchase/metrics', async (req, res) => { `); const { rows: [poMetrics] } = await executeQuery(` + WITH po_metrics AS ( + SELECT + po_id, + status, + date, + expected_date, + pid, + ordered, + po_cost_price + FROM purchase_orders po + WHERE po.status NOT IN ('canceled', 'done') + AND po.date >= CURRENT_DATE - INTERVAL '6 months' + ) SELECT - COALESCE(COUNT(DISTINCT CASE - WHEN po.status NOT IN ('canceled', 'done') - AND po.date >= CURRENT_DATE - INTERVAL '6 months' - THEN po.po_id - END), 0)::integer as active_pos, - COALESCE(COUNT(DISTINCT CASE - WHEN po.status NOT IN ('canceled', 'done') - AND po.date >= CURRENT_DATE - INTERVAL '6 months' - AND po.expected_date < CURRENT_DATE - THEN po.po_id - END), 0)::integer as overdue_pos, - COALESCE(SUM(CASE - WHEN po.status NOT IN ('canceled', 'done') - AND po.date >= CURRENT_DATE - INTERVAL '6 months' - THEN po.ordered - ELSE 0 - END), 0)::integer as total_units, - ROUND(COALESCE(SUM(CASE - WHEN po.status NOT IN ('canceled', 'done') - AND po.date >= CURRENT_DATE - INTERVAL '6 months' - THEN po.ordered * po.po_cost_price - ELSE 0 - END), 0)::numeric, 3) as total_cost, - ROUND(COALESCE(SUM(CASE - WHEN po.status NOT IN ('canceled', 'done') - AND po.date >= CURRENT_DATE - INTERVAL '6 months' - THEN po.ordered * pm.current_price - ELSE 0 - END), 0)::numeric, 3) as total_retail - FROM purchase_orders po + COUNT(DISTINCT po_id)::integer as active_pos, + COUNT(DISTINCT CASE WHEN expected_date < CURRENT_DATE THEN po_id END)::integer as overdue_pos, + SUM(ordered)::integer as total_units, + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost, + ROUND(SUM(ordered * pm.current_price)::numeric, 3) as total_retail + FROM po_metrics po JOIN product_metrics pm ON po.pid = pm.pid `); const { rows: vendorOrders } = await executeQuery(` + WITH po_by_vendor AS ( + SELECT + vendor, + po_id, + SUM(ordered) as total_ordered, + SUM(ordered * po_cost_price) as total_cost + FROM purchase_orders + WHERE status NOT IN ('canceled', 'done') + AND date >= CURRENT_DATE - INTERVAL '6 months' + GROUP BY vendor, po_id + ) SELECT - po.vendor, - COUNT(DISTINCT po.po_id)::integer as orders, - COALESCE(SUM(po.ordered), 0)::integer as units, - ROUND(COALESCE(SUM(po.ordered * po.po_cost_price), 0)::numeric, 3) as cost, - ROUND(COALESCE(SUM(po.ordered * pm.current_price), 0)::numeric, 3) as retail - FROM purchase_orders po + pv.vendor, + COUNT(DISTINCT pv.po_id)::integer as orders, + SUM(pv.total_ordered)::integer as units, + ROUND(SUM(pv.total_cost)::numeric, 3) as cost, + ROUND(SUM(pv.total_ordered * pm.current_price)::numeric, 3) as retail + FROM po_by_vendor pv + JOIN purchase_orders po ON pv.po_id = po.po_id JOIN product_metrics pm ON po.pid = pm.pid - WHERE po.status NOT IN ('canceled', 'done') - AND po.date >= CURRENT_DATE - INTERVAL '6 months' - GROUP BY po.vendor - HAVING ROUND(COALESCE(SUM(po.ordered * po.po_cost_price), 0)::numeric, 3) > 0 + GROUP BY pv.vendor + HAVING ROUND(SUM(pv.total_cost)::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 1fc319e..5a0d2d7 100644 --- a/inventory-server/src/routes/purchase-orders.js +++ b/inventory-server/src/routes/purchase-orders.js @@ -11,7 +11,12 @@ const STATUS = { PREORDERED: 12, ELECTRONICALLY_SENT: 13, RECEIVING_STARTED: 15, - DONE: 50 + DONE: 50, + // Receiving status codes + RECEIVING_CREATED: 1, + RECEIVING_PARTIAL: 30, + RECEIVING_FULL: 40, + RECEIVING_PAID: 50 }; // Status mapping from database string values to frontend numeric codes @@ -23,7 +28,11 @@ const STATUS_MAPPING = { 'preordered': STATUS.PREORDERED, 'electronically_sent': STATUS.ELECTRONICALLY_SENT, 'receiving_started': STATUS.RECEIVING_STARTED, - 'done': STATUS.DONE + 'done': STATUS.DONE, + // Receiving status mappings + 'partial_received': STATUS.RECEIVING_PARTIAL, + 'full_received': STATUS.RECEIVING_FULL, + 'paid': STATUS.RECEIVING_PAID }; // Helper for SQL status value comparison with string values in DB @@ -41,7 +50,18 @@ function getStatusWhereClause(statusNum) { router.get('/', async (req, res) => { try { const pool = req.app.locals.pool; - const { search, status, vendor, startDate, endDate, page = 1, limit = 100, sortColumn = 'date', sortDirection = 'desc' } = req.query; + const { + search, + status, + vendor, + recordType, + startDate, + endDate, + page = 1, + limit = 100, + sortColumn = 'id', + sortDirection = 'desc' + } = req.query; let whereClause = '1=1'; const params = []; @@ -88,13 +108,11 @@ router.get('/', async (req, res) => { ), receiving_totals AS ( SELECT - r.receiving_id, - po.po_id, + r.receiving_id as po_id, SUM(r.qty_each) as total_received FROM receivings r - JOIN purchase_orders po ON r.pid = po.pid - WHERE po.po_id IN (SELECT po_id FROM po_totals) - GROUP BY r.receiving_id, po.po_id + WHERE r.receiving_id IN (SELECT po_id FROM po_totals) + GROUP BY r.receiving_id ) SELECT COUNT(DISTINCT po.po_id) as order_count, @@ -109,42 +127,120 @@ router.get('/', async (req, res) => { LEFT JOIN receiving_totals r ON po.po_id = r.po_id `, params); - // Get total count for pagination - const { rows: [countResult] } = await pool.query(` - SELECT COUNT(DISTINCT po_id) as total - FROM purchase_orders po - WHERE ${whereClause} - `, params); + // Get total count for pagination (including both POs and receivings without POs) + let countQuery = ` + WITH po_count AS ( + SELECT COUNT(DISTINCT po_id) as po_count + FROM purchase_orders po + WHERE ${whereClause} + ), + receiving_count AS ( + SELECT COUNT(DISTINCT receiving_id) as r_count + FROM receivings r + WHERE receiving_id NOT IN ( + SELECT po_id FROM purchase_orders po WHERE ${whereClause} + ) + )`; + + // Adjust count query based on record type filter + if (recordType && recordType !== 'all') { + if (recordType === 'po_only') { + countQuery = ` + WITH po_count AS ( + SELECT COUNT(DISTINCT po.po_id) as po_count + FROM purchase_orders po + LEFT JOIN ( + SELECT DISTINCT receiving_id + FROM receivings + ) r ON po.po_id = r.receiving_id + WHERE ${whereClause} AND r.receiving_id IS NULL + ), + receiving_count AS ( + SELECT 0 as r_count + )`; + } else if (recordType === 'po_with_receiving') { + countQuery = ` + WITH po_count AS ( + SELECT COUNT(DISTINCT po.po_id) as po_count + FROM purchase_orders po + INNER JOIN ( + SELECT DISTINCT receiving_id + FROM receivings + ) r ON po.po_id = r.receiving_id + WHERE ${whereClause} + ), + receiving_count AS ( + SELECT 0 as r_count + )`; + } else if (recordType === 'receiving_only') { + countQuery = ` + WITH po_count AS ( + SELECT 0 as po_count + ), + receiving_count AS ( + SELECT COUNT(DISTINCT receiving_id) as r_count + FROM receivings r + WHERE receiving_id NOT IN ( + SELECT po_id FROM purchase_orders po WHERE ${whereClause} + ) + )`; + } + } + + countQuery += ` + SELECT (SELECT po_count FROM po_count) + (SELECT r_count FROM receiving_count) as total + `; + + const { rows: [countResult] } = await pool.query(countQuery, params); const total = countResult.total; const offset = (page - 1) * limit; const pages = Math.ceil(total / limit); - // Get recent purchase orders - let orderByClause; + // Set default sorting for id to ensure consistent ordering + const defaultSortColumn = sortColumn || 'id'; + const defaultSortDirection = sortDirection || 'desc'; + + // Get recent purchase orders - build the base query + let orderByClause = ''; - if (sortColumn === 'order_date') { - orderByClause = `date ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; - } else if (sortColumn === 'vendor_name') { - orderByClause = `vendor ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; - } else if (sortColumn === 'total_cost') { - orderByClause = `total_cost ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; - } else if (sortColumn === 'total_received') { - orderByClause = `total_received ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; - } else if (sortColumn === 'total_items') { - orderByClause = `total_items ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; - } else if (sortColumn === 'total_quantity') { - orderByClause = `total_quantity ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; - } else if (sortColumn === 'fulfillment_rate') { - orderByClause = `fulfillment_rate ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; - } else if (sortColumn === 'status') { - orderByClause = `status ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; + // Special sorting that ensures receiving_only records are included with any date sorting + if (defaultSortColumn === 'order_date' || defaultSortColumn === 'date') { + // Make sure to include receivings (which have NULL order_date) by using a CASE statement + orderByClause = ` + CASE + WHEN order_date IS NULL THEN + CASE WHEN receiving_date IS NOT NULL THEN + to_date(receiving_date, 'YYYY-MM-DD') + ELSE + '1900-01-01'::date + END + ELSE + to_date(order_date, 'YYYY-MM-DD') + END ${defaultSortDirection === 'desc' ? 'DESC' : 'ASC'} + `; + } else if (defaultSortColumn === 'vendor_name') { + orderByClause = `vendor_name ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; + } else if (defaultSortColumn === 'total_cost') { + orderByClause = `total_cost ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; + } else if (defaultSortColumn === 'total_received') { + orderByClause = `total_received ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; + } else if (defaultSortColumn === 'total_items') { + orderByClause = `total_items ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; + } else if (defaultSortColumn === 'total_quantity') { + orderByClause = `total_quantity ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; + } else if (defaultSortColumn === 'fulfillment_rate') { + orderByClause = `fulfillment_rate ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; + } else if (defaultSortColumn === 'status') { + orderByClause = `status ${defaultSortDirection === 'desc' ? 'DESC NULLS LAST' : 'ASC NULLS FIRST'}`; } else { - orderByClause = `date ${sortDirection === 'desc' ? 'DESC' : 'ASC'}`; + // Default to ID sorting if no valid column is specified + orderByClause = `id::bigint ${defaultSortDirection === 'desc' ? 'DESC' : 'ASC'}`; } - const { rows: orders } = await pool.query(` - WITH po_totals AS ( + // Simplified combined query approach to ensure all record types are included + let orderQuery = ` + WITH po_data AS ( SELECT po_id, vendor, @@ -152,37 +248,79 @@ router.get('/', async (req, res) => { status, COUNT(DISTINCT pid) as total_items, SUM(ordered) as total_quantity, - ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost + ROUND(SUM(ordered * po_cost_price)::numeric, 3) as total_cost, + MAX(notes) as short_note FROM purchase_orders po WHERE ${whereClause} GROUP BY po_id, vendor, date, status ), - receiving_totals AS ( + receiving_data AS ( SELECT - po.po_id, - SUM(r.qty_each) as total_received + r.receiving_id, + MAX(r.received_date) as receiving_date, + r.vendor as receiving_vendor, + COUNT(DISTINCT r.pid) as total_items, + SUM(r.qty_each) as total_received, + ROUND(SUM(r.qty_each * r.cost_each)::numeric, 3) as total_cost, + MAX(r.status) as receiving_status FROM receivings r - JOIN purchase_orders po ON r.pid = po.pid - WHERE po.po_id IN (SELECT po_id FROM po_totals) - GROUP BY po.po_id + GROUP BY r.receiving_id, r.vendor + ), + all_data AS ( + -- Get all unique IDs from both tables + SELECT DISTINCT po_id as id FROM po_data + UNION + SELECT DISTINCT receiving_id as id FROM receiving_data + ${recordType === 'po_only' ? + 'EXCEPT SELECT DISTINCT receiving_id as id FROM receiving_data' : + recordType === 'po_with_receiving' ? + 'INTERSECT SELECT DISTINCT receiving_id as id FROM receiving_data' : + recordType === 'receiving_only' ? + 'EXCEPT SELECT DISTINCT po_id as id FROM po_data' : + '' // No additional clause for 'all' + } + ), + combined_data AS ( + SELECT + a.id, + COALESCE(po.vendor, r.receiving_vendor) as vendor_name, + to_char(po.date, 'YYYY-MM-DD') as order_date, + to_char(r.receiving_date, 'YYYY-MM-DD') as receiving_date, + CASE + WHEN po.po_id IS NULL THEN r.receiving_status + ELSE po.status + END as status, + COALESCE(po.total_items, r.total_items, 0) as total_items, + COALESCE(po.total_quantity, 0) as total_quantity, + COALESCE(po.total_cost, r.total_cost, 0) as total_cost, + COALESCE(r.total_received, 0) as total_received, + CASE + WHEN po.po_id IS NULL THEN 1 + WHEN r.receiving_id IS NULL THEN 0 + ELSE ROUND((r.total_received::numeric / NULLIF(po.total_quantity, 0)), 3) + END as fulfillment_rate, + po.short_note, + CASE + WHEN po.po_id IS NULL THEN 'receiving_only' + WHEN r.receiving_id IS NULL THEN 'po_only' + ELSE 'po_with_receiving' + END as record_type + FROM all_data a + LEFT JOIN po_data po ON a.id = po.po_id + LEFT JOIN receiving_data r ON a.id = r.receiving_id + ${ + recordType === 'po_only' ? 'WHERE po.po_id IS NOT NULL AND r.receiving_id IS NULL' : + recordType === 'po_with_receiving' ? 'WHERE po.po_id IS NOT NULL AND r.receiving_id IS NOT NULL' : + recordType === 'receiving_only' ? 'WHERE po.po_id IS NULL AND r.receiving_id IS NOT NULL' : + '' // No WHERE clause for 'all' + } ) - SELECT - pt.po_id as id, - pt.vendor as vendor_name, - to_char(pt.date, 'YYYY-MM-DD') as order_date, - pt.status, - pt.total_items, - pt.total_quantity, - pt.total_cost, - COALESCE(rt.total_received, 0) as total_received, - ROUND( - (COALESCE(rt.total_received, 0)::numeric / NULLIF(pt.total_quantity, 0)), 3 - ) as fulfillment_rate - FROM po_totals pt - LEFT JOIN receiving_totals rt ON pt.po_id = rt.po_id - ORDER BY ${orderByClause} + SELECT * FROM combined_data + ORDER BY ${orderByClause}, id::bigint DESC LIMIT $${paramCounter} OFFSET $${paramCounter + 1} - `, [...params, Number(limit), offset]); + `; + + const { rows: orders } = await pool.query(orderQuery, [...params, Number(limit), offset]); // Get unique vendors for filter options const { rows: vendors } = await pool.query(` @@ -224,17 +362,32 @@ router.get('/', async (req, res) => { } // Parse numeric values and map status strings to numeric codes - const parsedOrders = orders.map(order => ({ - id: order.id, - vendor_name: vendorMappings[order.id] || order.vendor_name, - order_date: order.order_date, - status: STATUS_MAPPING[order.status] || 0, // Map string 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, - total_received: Number(order.total_received) || 0, - fulfillment_rate: Number(order.fulfillment_rate) || 0 - })); + const parsedOrders = orders.map(order => { + // Special handling for status mapping + let statusCode; + if (order.record_type === 'receiving_only') { + // For receiving-only records, use receiving status codes + statusCode = STATUS_MAPPING[order.status] || 0; + } else { + // For PO records, use PO status codes + statusCode = STATUS_MAPPING[order.status] || 0; + } + + return { + id: order.id, + vendor_name: vendorMappings[order.id] || order.vendor_name, + order_date: order.order_date, + receiving_date: order.receiving_date, + status: statusCode, + 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, + short_note: order.short_note, + record_type: order.record_type + }; + }); // Parse summary metrics const parsedSummary = { @@ -276,40 +429,39 @@ router.get('/vendor-metrics', async (req, res) => { SELECT vendor, po_id, - ordered, - po_cost_price, - date + SUM(ordered) as total_ordered, + AVG(po_cost_price) as avg_cost_price, + MAX(date) as po_date FROM purchase_orders WHERE vendor IS NOT NULL AND vendor != '' AND status != 'canceled' -- Exclude canceled orders + GROUP BY vendor, po_id ), receiving_data AS ( SELECT - po.po_id, - po.vendor, - r.pid, - r.qty_each as received, - r.received_date + r.receiving_id as po_id, + SUM(r.qty_each) as total_received, + MIN(r.received_date) as first_received_date FROM receivings r - JOIN purchase_orders po ON r.pid = po.pid + JOIN purchase_orders po ON r.receiving_id = po.po_id WHERE po.vendor IS NOT NULL AND po.vendor != '' AND po.status != 'canceled' + GROUP BY r.receiving_id ), delivery_metrics AS ( SELECT po.vendor, po.po_id, - po.ordered, - COALESCE(SUM(r.received), 0) as received, - po.po_cost_price, + po.total_ordered as ordered, + COALESCE(r.total_received, 0) as received, + po.avg_cost_price as po_cost_price, CASE - WHEN MIN(r.received_date) IS NOT NULL AND po.date IS NOT NULL - THEN (MIN(r.received_date) - po.date)::integer + WHEN r.first_received_date IS NOT NULL AND po.po_date IS NOT NULL + THEN EXTRACT(DAY FROM (r.first_received_date - po.po_date)) ELSE NULL END as delivery_days FROM po_data po LEFT JOIN receiving_data r ON po.po_id = r.po_id - GROUP BY po.vendor, po.po_id, po.ordered, po.po_cost_price, po.date ) SELECT vendor as vendor_name, @@ -428,6 +580,119 @@ router.get('/cost-analysis', async (req, res) => { } }); +// New endpoint for yearly category spending analysis based on receivings +router.get('/category-analysis', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Allow an optional "since" parameter or default to 1 year ago + const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const { rows: analysis } = await pool.query(` + WITH receiving_costs AS ( + SELECT + c.name as category, + r.pid, + r.cost_each as received_cost, + r.qty_each as received_qty, + r.received_date + FROM receivings r + JOIN product_categories pc ON r.pid = pc.pid + JOIN categories c ON pc.cat_id = c.cat_id + WHERE r.received_date >= $1::date + AND r.qty_each > 0 -- Only consider actual received quantities + ) + SELECT + category, + COUNT(DISTINCT pid) as unique_products, + ROUND(AVG(received_cost)::numeric, 3) as avg_cost, + ROUND(MIN(received_cost)::numeric, 3) as min_cost, + ROUND(MAX(received_cost)::numeric, 3) as max_cost, + ROUND(STDDEV(received_cost)::numeric, 3) as cost_variance, + ROUND(SUM(received_qty * received_cost)::numeric, 3) as total_spend + FROM receiving_costs + GROUP BY category + ORDER BY total_spend DESC + `, [since]); + + // Parse numeric values + const parsedAnalysis = 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 + })); + + res.json(parsedAnalysis); + } catch (error) { + console.error('Error fetching category analysis:', error); + res.status(500).json({ error: 'Failed to fetch category analysis' }); + } +}); + +// New endpoint for yearly vendor spending analysis based on receivings +router.get('/vendor-analysis', async (req, res) => { + try { + const pool = req.app.locals.pool; + + // Allow an optional "since" parameter or default to 1 year ago + const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0]; + + const { rows: metrics } = await pool.query(` + WITH receiving_data AS ( + SELECT + r.vendor, + r.receiving_id, + r.pid, + r.qty_each, + r.cost_each, + r.received_date + FROM receivings r + WHERE r.received_date >= $1::date + AND r.qty_each > 0 -- Only consider actual received quantities + ), + receiving_totals AS ( + SELECT + vendor, + receiving_id, + COUNT(DISTINCT pid) as unique_products, + SUM(qty_each) as total_received, + SUM(qty_each * cost_each) as total_spend + FROM receiving_data + GROUP BY vendor, receiving_id + ) + SELECT + vendor, + COUNT(DISTINCT receiving_id) as orders, + ROUND(SUM(total_spend)::numeric, 3) as total_spend, + SUM(total_received) as total_received, + SUM(unique_products) as total_items + FROM receiving_totals + WHERE vendor IS NOT NULL AND vendor != '' + GROUP BY vendor + HAVING COUNT(DISTINCT receiving_id) > 0 + ORDER BY total_spend DESC + `, [since]); + + // Parse numeric values + const parsedMetrics = metrics.map(vendor => ({ + vendor: vendor.vendor, + orders: Number(vendor.orders) || 0, + total_spend: Number(vendor.total_spend) || 0, + total_received: Number(vendor.total_received) || 0, + total_items: Number(vendor.total_items) || 0 + })); + + res.json(parsedMetrics); + } catch (error) { + console.error('Error fetching vendor analysis:', error); + res.status(500).json({ error: 'Failed to fetch vendor analysis' }); + } +}); + // Get order status metrics router.get('/receiving-status', async (req, res) => { try { @@ -449,33 +714,42 @@ router.get('/receiving-status', async (req, res) => { po.po_id, SUM(r.qty_each) as total_received FROM receivings r - JOIN purchase_orders po ON r.pid = po.pid + JOIN purchase_orders po ON r.pid = po.pid AND r.sku = po.sku WHERE po.po_id IN (SELECT po_id FROM po_totals) GROUP BY po.po_id + ), + combined_data AS ( + SELECT + po.po_id, + po.status, + po.total_ordered, + po.total_cost, + COALESCE(r.total_received, 0) as total_received + FROM po_totals po + LEFT JOIN receiving_totals r ON po.po_id = r.po_id ) SELECT - COUNT(DISTINCT po.po_id) as order_count, - SUM(po.total_ordered) as total_ordered, - COALESCE(SUM(r.total_received), 0) as total_received, + COUNT(DISTINCT po_id) as order_count, + SUM(total_ordered) as total_ordered, + SUM(total_received) as total_received, ROUND( - COALESCE(SUM(r.total_received), 0) / NULLIF(SUM(po.total_ordered), 0), 3 + SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3 ) as fulfillment_rate, - ROUND(SUM(po.total_cost)::numeric, 3) as total_value, - ROUND(AVG(po.total_cost)::numeric, 3) as avg_cost, + ROUND(SUM(total_cost)::numeric, 3) as total_value, + ROUND(AVG(total_cost)::numeric, 3) as avg_cost, COUNT(DISTINCT CASE - WHEN po.status = 'created' THEN po.po_id + WHEN status = 'created' THEN po_id END) as pending_count, COUNT(DISTINCT CASE - WHEN po.status = 'receiving_started' THEN po.po_id + WHEN status = 'receiving_started' THEN po_id END) as partial_count, COUNT(DISTINCT CASE - WHEN po.status = 'done' THEN po.po_id + WHEN status = 'done' THEN po_id END) as completed_count, COUNT(DISTINCT CASE - WHEN po.status = 'canceled' THEN po.po_id + WHEN status = 'canceled' THEN po_id END) as canceled_count - FROM po_totals po - LEFT JOIN receiving_totals r ON po.po_id = r.po_id + FROM combined_data `); // Parse numeric values @@ -524,7 +798,7 @@ router.get('/order-vs-received', async (req, res) => { r.pid, SUM(r.qty_each) as received_quantity FROM receivings r - JOIN products p ON r.pid = p.pid + JOIN purchase_orders po ON r.receiving_id = po.po_id WHERE r.received_date >= (CURRENT_DATE - INTERVAL '90 days') GROUP BY r.pid ) @@ -540,7 +814,7 @@ router.get('/order-vs-received', async (req, res) => { o.order_count FROM order_data o LEFT JOIN receiving_data r ON o.pid = r.pid - HAVING o.order_count > 0 + WHERE o.order_count > 0 ORDER BY o.ordered_quantity DESC LIMIT 20 `); diff --git a/inventory/src/pages/PurchaseOrders.tsx b/inventory/src/pages/PurchaseOrders.tsx index 1e1887b..c882847 100644 --- a/inventory/src/pages/PurchaseOrders.tsx +++ b/inventory/src/pages/PurchaseOrders.tsx @@ -1,10 +1,11 @@ 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, Info, BarChart3 } from 'lucide-react'; +import { ArrowUpDown, BarChart3, FileText, PieChart as PieChartIcon, Loader2 } from 'lucide-react'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; import { Badge } from '../components/ui/badge'; +import { Skeleton } from '../components/ui/skeleton'; import { Select, SelectContent, @@ -30,12 +31,10 @@ import { import { Dialog, DialogContent, - DialogDescription, DialogHeader, DialogTitle, DialogTrigger, } from "../components/ui/dialog"; -import { motion } from 'motion/react'; import { PurchaseOrderStatus, getPurchaseOrderStatusLabel, @@ -43,17 +42,21 @@ import { getPurchaseOrderStatusVariant, getReceivingStatusVariant } from '../types/status-codes'; +import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"; interface PurchaseOrder { id: number | string; vendor_name: string; - order_date: string; + order_date: string | null; + receiving_date: string | null; status: number; total_items: number; total_quantity: number; total_cost: number; total_received: number; fulfillment_rate: number; + short_note: string | null; + record_type: 'po_only' | 'po_with_receiving' | 'receiving_only'; } interface VendorMetrics { @@ -111,6 +114,100 @@ interface PurchaseOrdersResponse { }; } +// Add this constant for pie chart colors +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FFC658", + "#FF7C43", +]; + +// Add this function to render active slice of pie chart +const renderActiveShape = (props: any) => { + const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, category, total_spend, percentage } = props; + + // Split category name into words and create lines of max 12 chars + const words = category.split(' '); + const lines: string[] = []; + let currentLine = ''; + + words.forEach((word: string) => { + if ((currentLine + ' ' + word).length <= 12) { + currentLine = currentLine ? `${currentLine} ${word}` : word; + } else { + if (currentLine) lines.push(currentLine); + currentLine = word; + } + }); + if (currentLine) lines.push(currentLine); + + return ( + + + + {lines.map((line, i) => ( + + {line} + + ))} + + {`$${total_spend.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })}`} + + + {`${(percentage * 100).toLocaleString('en-US', { + minimumFractionDigits: 1, + maximumFractionDigits: 1 + })}%`} + + + ); +}; + export default function PurchaseOrders() { const [purchaseOrders, setPurchaseOrders] = useState([]); const [, setVendorMetrics] = useState([]); @@ -124,6 +221,7 @@ export default function PurchaseOrders() { search: '', status: 'all', vendor: 'all', + recordType: 'all', }); const [filterOptions, setFilterOptions] = useState<{ vendors: string[]; @@ -139,6 +237,26 @@ export default function PurchaseOrders() { limit: 100, }); const [costAnalysisOpen, setCostAnalysisOpen] = useState(false); + const [spendingChartOpen, setSpendingChartOpen] = useState(false); + const [activeSpendingIndex, setActiveSpendingIndex] = useState(); + const [vendorChartOpen, setVendorChartOpen] = useState(false); + const [activeVendorIndex, setActiveVendorIndex] = useState(); + const [vendorAnalysisOpen, setVendorAnalysisOpen] = useState(false); + const [yearlyVendorData, setYearlyVendorData] = useState<{ + vendor: string; + orders: number; + total_spend: number; + percentage?: number; + }[]>([]); + const [yearlyCategoryData, setYearlyCategoryData] = useState<{ + category: string; + unique_products?: number; + total_spend: number; + percentage?: number; + avg_cost?: number; + cost_variance?: number; + }[]>([]); + const [yearlyDataLoading, setYearlyDataLoading] = useState(false); const STATUS_FILTER_OPTIONS = [ { value: 'all', label: 'All Statuses' }, @@ -150,6 +268,13 @@ export default function PurchaseOrders() { { value: String(PurchaseOrderStatus.Canceled), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled) }, ]; + const RECORD_TYPE_FILTER_OPTIONS = [ + { value: 'all', label: 'All Records' }, + { value: 'po_only', label: 'PO Only' }, + { value: 'po_with_receiving', label: 'PO with Receiving' }, + { value: 'receiving_only', label: 'Receiving Only' }, + ]; + const fetchData = async () => { try { setLoading(true); @@ -161,8 +286,9 @@ export default function PurchaseOrders() { ...filters.search && { search: filters.search }, ...filters.status !== 'all' && { status: filters.status }, ...filters.vendor !== 'all' && { vendor: filters.vendor }, + ...filters.recordType !== 'all' && { recordType: filters.recordType }, }); - + const [ purchaseOrdersRes, vendorMetricsRes, @@ -287,8 +413,14 @@ export default function PurchaseOrders() { } }; - const getStatusBadge = (status: number) => { - return + const getStatusBadge = (status: number, recordType: string) => { + if (recordType === 'receiving_only') { + return + {getReceivingStatusLabel(status)} + ; + } + + return {getPurchaseOrderStatusLabel(status)} ; }; @@ -314,7 +446,6 @@ export default function PurchaseOrders() { // Generate pagination items const getPaginationItems = () => { const items = []; - const maxPagesToShow = 5; const totalPages = pagination.pages; // Always show first page @@ -384,165 +515,632 @@ export default function PurchaseOrders() { return items; }; - // Cost Analysis table component + // Update this function to fetch yearly data + const fetchYearlyData = async () => { + try { + setYearlyDataLoading(true); + + // Create a date for 1 year ago + const oneYearAgo = new Date(); + oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1); + const dateParam = oneYearAgo.toISOString().split('T')[0]; // Format as YYYY-MM-DD + + const [vendorResponse, categoryResponse] = await Promise.all([ + fetch(`/api/purchase-orders/vendor-analysis?since=${dateParam}`), + fetch(`/api/purchase-orders/category-analysis?since=${dateParam}`) + ]); + + if (vendorResponse.ok) { + const vendorData = await vendorResponse.json(); + // Calculate percentages before setting state + const totalSpend = vendorData.reduce((sum: number, v: any) => sum + v.total_spend, 0); + + setYearlyVendorData(vendorData.map((v: any) => ({ + ...v, + percentage: totalSpend > 0 ? v.total_spend / totalSpend : 0 + }))); + } else { + console.error('Failed to fetch yearly vendor data:', await vendorResponse.text()); + } + + if (categoryResponse.ok) { + const categoryData = await categoryResponse.json(); + // Calculate percentages before setting state + const totalSpend = categoryData.reduce((sum: number, c: any) => sum + c.total_spend, 0); + + setYearlyCategoryData(categoryData.map((c: any) => ({ + ...c, + percentage: totalSpend > 0 ? c.total_spend / totalSpend : 0 + }))); + } else { + console.error('Failed to fetch yearly category data:', await categoryResponse.text()); + } + } catch (error) { + console.error('Error fetching yearly data:', error); + } finally { + setYearlyDataLoading(false); + } + }; + + // Fetch yearly data when component mounts, not just when dialogs open + useEffect(() => { + fetchYearlyData(); + }, []); + + // Update the CostAnalysisTable to always show yearly data const CostAnalysisTable = () => { - if (!costAnalysis) return null; + if (!yearlyCategoryData.length) { + return yearlyDataLoading ? ( +
+ +
+ ) : ( +
+ No category data available for the past 12 months +
+ ); + } 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)} - +
+ {yearlyDataLoading ? ( +
+ +
+ ) : ( + <> +
+ Showing received inventory by category for the past 12 months + {yearlyCategoryData.length} categories found +
+
+ + + Category + Products + Avg. Cost + Price Variance + Total Spend + % of Total - ); - }) : ( - - - No cost analysis data available - - - ) - } - -
+ + + {yearlyCategoryData.map((category) => { + // Calculate percentage of total spend + const totalSpendPercentage = 'percentage' in category && typeof category.percentage === 'number' + ? category.percentage + : (yearlyCategoryData.reduce((sum, cat) => sum + cat.total_spend, 0) > 0 + ? (category.total_spend / + yearlyCategoryData.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)} + + + ); + })} + + + + )} + ); }; - if (loading) { + // Display a record type indicator with appropriate styling + const getRecordTypeIndicator = (recordType: string) => { + switch (recordType) { + case 'po_with_receiving': + return ( + + Received PO + + ); + case 'po_only': + return ( + + PO + + ); + case 'receiving_only': + return ( + + Receiving + + ); + default: + return ( + + {recordType || 'Unknown'} + + ); + } + }; + + // Update the prepareSpendingChartData to use yearly data + const prepareSpendingChartData = () => { + // Only use yearly data, no fallback + if (!yearlyCategoryData.length) return []; + + // Make a copy to avoid modifying state directly + const categoryArray = [...yearlyCategoryData]; + const totalSpend = categoryArray.reduce((sum, cat) => sum + cat.total_spend, 0); + + // Split into significant categories (>=1%) and others + const significantCategories = categoryArray + .filter(cat => cat.total_spend / totalSpend >= 0.01); + + const otherCategories = categoryArray + .filter(cat => cat.total_spend / totalSpend < 0.01); + + let result = [...significantCategories]; + + // Add "Other" category if needed + if (otherCategories.length > 0) { + const otherTotalSpend = otherCategories.reduce( + (sum, cat) => sum + cat.total_spend, 0 + ); + + result.push({ + category: 'Other', + total_spend: otherTotalSpend, + percentage: otherTotalSpend / totalSpend, + unique_products: otherCategories.reduce( + (sum, cat) => sum + (cat.unique_products || 0), 0 + ), + avg_cost: otherTotalSpend / otherCategories.reduce( + (sum, cat) => sum + (cat.unique_products || 0), 1 + ), + cost_variance: 0 + }); + } + + // Sort by spend amount descending + return result.sort((a, b) => b.total_spend - a.total_spend); + }; + + // Update the existing prepareVendorChartData to use the yearly data + const prepareVendorChartData = () => { + // Only use yearly data, no fallback + if (!yearlyVendorData.length) return []; + + // Make a copy to avoid modifying state directly + const vendorArray = [...yearlyVendorData]; + const totalSpend = vendorArray.reduce((sum, vendor) => sum + vendor.total_spend, 0); + + // Split into significant vendors (>=1%) and others + const significantVendors = vendorArray + .filter(vendor => vendor.total_spend / totalSpend >= 0.01); + + const otherVendors = vendorArray + .filter(vendor => vendor.total_spend / totalSpend < 0.01); + + let result = [...significantVendors]; + + // Add "Other" category if needed + if (otherVendors.length > 0) { + const otherTotalSpend = otherVendors.reduce( + (sum, vendor) => sum + vendor.total_spend, 0 + ); + + result.push({ + vendor: 'Other Vendors', + total_spend: otherTotalSpend, + percentage: otherTotalSpend / totalSpend, + orders: otherVendors.reduce((sum, vendor) => sum + vendor.orders, 0) + }); + } + + // Sort by spend amount descending + return result.sort((a, b) => b.total_spend - a.total_spend); + }; + + // Add a new function to get all vendors for the table (no grouping) + const getAllVendorsForTable = () => { + // Now only use yearlyVendorData and never fall back to current page data + if (!yearlyVendorData.length) return []; + + return [...yearlyVendorData].sort((a, b) => b.total_spend - a.total_spend); + }; + + // Update the VendorAnalysisTable to always show yearly data + const VendorAnalysisTable = () => { + const vendorData = getAllVendorsForTable(); + + if (!vendorData.length) { + return yearlyDataLoading ? ( +
+ +
+ ) : ( +
+ No vendor data available for the past 12 months +
+ ); + } + return ( -
- +
+ {yearlyDataLoading ? ( +
+ +
+ ) : ( + <> +
+ Showing received inventory by vendor for the past 12 months + {vendorData.length} vendors found +
+ + + + Vendor + Orders + Total Spend + % of Total + Avg. Order Value + + + + {vendorData.map((vendor) => { + return ( + + + {vendor.vendor} + + + {vendor.orders.toLocaleString()} + + + {formatCurrency(vendor.total_spend)} + + + {formatPercent(vendor.percentage || 0)} + + + {formatCurrency(vendor.orders ? vendor.total_spend / vendor.orders : 0)} + + + ); + })} + +
+ + )}
); - } + }; + + // Function to render the revised metrics cards + const renderMetricsCards = () => ( +
+ {/* Combined Metrics Card */} + + + Order Metrics + + + {loading ? ( + <> + + + + ) : ( +
+
+

Avg. Cost per PO

+

+ {formatCurrency(summary?.avg_cost || 0)} +

+
+
+

Fulfillment Rate

+

+ {formatPercent(summary?.fulfillment_rate || 0)} +

+
+
+

Total Orders

+

{summary?.order_count.toLocaleString() || 0}

+
+
+ )} +
+
+ + {/* Category Spending Chart Card */} + + + Received by Category + + + + + + + + + Received Inventory by Category + + +
+
+ + + setActiveSpendingIndex(index)} + onMouseLeave={() => setActiveSpendingIndex(undefined)} + > + {prepareSpendingChartData().map((entry, index) => ( + + ))} + + + +
+ + {/* Legend */} +
+ {prepareSpendingChartData().map((entry, index) => ( +
+
+ {entry.category}: {`$${entry.total_spend.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })}`} +
+ ))} +
+
+ +
+
+ + {loading ? ( + + ) : ( + <> +
+ + + setActiveSpendingIndex(index)} + onMouseLeave={() => setActiveSpendingIndex(undefined)} + > + {prepareSpendingChartData().map((entry, index) => ( + + ))} + + + +
+ + + + + + + + + + Received Inventory by Category + + +
+ +
+
+
+ + )} +
+
+ + {/* Vendor Spending Chart Card */} + + + Received by Vendor + + + + + + + + + Received Inventory by Vendor + + +
+
+ + + renderActiveShape({...props, category: props.vendor})} + onMouseEnter={(_, index) => setActiveVendorIndex(index)} + onMouseLeave={() => setActiveVendorIndex(undefined)} + > + {prepareVendorChartData().map((entry, index) => ( + + ))} + + + +
+ + {/* Legend */} +
+ {prepareVendorChartData().map((entry, index) => ( +
+
+ {entry.vendor}: {`$${entry.total_spend.toLocaleString('en-US', { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + })}`} +
+ ))} +
+
+ +
+
+ + {loading ? ( + + ) : ( + <> +
+ + + renderActiveShape({...props, category: props.vendor})} + onMouseEnter={(_, index) => setActiveVendorIndex(index)} + onMouseLeave={() => setActiveVendorIndex(undefined)} + > + {prepareVendorChartData().map((entry, index) => ( + + ))} + + + +
+ + + + + + + + + + Received Inventory by Vendor + + +
+ +
+
+
+ + )} +
+
+
+ ); return ( - +

Purchase Orders

{/* Metrics Overview */} -
- - - Total Orders - - -
{summary?.order_count.toLocaleString() || 0}
-
-
- - - Total Value - - -
- {formatCurrency(summary?.total_value || 0)} -
-
-
- - - Fulfillment Rate - - -
- {formatPercent(summary?.fulfillment_rate || 0)} -
-
-
- - - Spending Analysis - - - - - - - - - Purchase Order Spending Analysis by Category - - - This analysis shows spending distribution across product categories - - -
- -
-
-
-
- -
- {formatCurrency(summary?.avg_cost || 0)} -
- Avg. Cost per PO -
-
- -
-
-
+ {renderMetricsCards()} {/* Filters */} -
+
setFilters(prev => ({ ...prev, search: e.target.value }))} className="max-w-xs" + disabled={loading} /> setFilters(prev => ({ ...prev, vendor: value }))} + disabled={loading} > @@ -571,71 +1170,153 @@ export default function PurchaseOrders() { ))} +
{/* Purchase Orders Table */} - - Recent Purchase Orders + + Purchase Orders & Receivings +
+ {loading ? ( + + ) : ( + `${summary?.order_count.toLocaleString()} orders` + )} +
- + + Type + + - + Rec'd Date - - - - + Notes Total Items Total Quantity - Received - - {purchaseOrders.map((po) => ( - - {po.id} - {po.vendor_name} - {new Date(po.order_date).toLocaleDateString()} - {getStatusBadge(po.status)} - {po.total_items.toLocaleString()} - {po.total_quantity.toLocaleString()} - {formatCurrency(po.total_cost)} - {po.total_received.toLocaleString()} - - {po.fulfillment_rate === null ? 'N/A' : formatPercent(po.fulfillment_rate)} - - - ))} - {!purchaseOrders.length && ( + {loading ? ( + // Skeleton rows for loading state + Array(50).fill(0).map((_, index) => ( + + + + + + + + + + + + + + + )) + ) : purchaseOrders.length > 0 ? ( + purchaseOrders.map((po) => { + // Determine row styling based on record type + let rowClassName = 'border-l-4 border-l-gray-300'; // Default + + if (po.record_type === 'po_with_receiving') { + rowClassName = 'border-l-4 border-l-green-500'; + } else if (po.record_type === 'po_only') { + rowClassName = 'border-l-4 border-l-blue-500'; + } else if (po.record_type === 'receiving_only') { + rowClassName = 'border-l-4 border-l-amber-500'; + } + + return ( + + + {po.id} + + + {getRecordTypeIndicator(po.record_type)} + + {po.vendor_name} + {po.order_date ? new Date(po.order_date).toLocaleDateString() : ''} + {po.receiving_date ? new Date(po.receiving_date).toLocaleDateString() : ''} + {getStatusBadge(po.status, po.record_type)} + + {po.short_note ? ( + + + + + {po.short_note} + + +

{po.short_note}

+
+
+
+ ) : 'N/A'} +
+ {po.total_items.toLocaleString()} + {po.total_quantity.toLocaleString()} + {formatCurrency(po.total_cost)} + {po.total_received.toLocaleString()} + + {po.fulfillment_rate === null ? 'N/A' : formatPercent(po.fulfillment_rate)} + +
+ ); + }) + ) : ( - + No purchase orders found @@ -679,6 +1360,6 @@ export default function PurchaseOrders() { )} - + ); } \ No newline at end of file diff --git a/inventory/src/types/status-codes.ts b/inventory/src/types/status-codes.ts index 1adfe06..3aaeefe 100644 --- a/inventory/src/types/status-codes.ts +++ b/inventory/src/types/status-codes.ts @@ -75,7 +75,7 @@ export function getPurchaseOrderStatusVariant(status: number): 'default' | 'seco export function getReceivingStatusVariant(status: number): 'default' | 'secondary' | 'destructive' | 'outline' { if (isReceivingCanceled(status)) return 'destructive'; - if (status === ReceivingStatus.Paid) return 'default'; + if (status === ReceivingStatus.Paid || status === ReceivingStatus.FullReceived) return 'default'; if (status >= ReceivingStatus.PartialReceived) return 'secondary'; return 'outline'; } \ No newline at end of file