From 2fed979b1d2c4de030f8f90f7e1caca4ffa94112 Mon Sep 17 00:00:00 2001 From: Matt Date: Fri, 10 Jan 2025 21:31:46 -0500 Subject: [PATCH] Add purchase orders page with some bugs left --- .../src/routes/purchase-orders.js | 315 +++++++++++++ inventory-server/src/server.js | 2 + inventory/src/App.tsx | 4 +- .../src/components/layout/AppSidebar.tsx | 7 +- inventory/src/pages/PurchaseOrders.tsx | 445 ++++++++++++++++++ 5 files changed, 771 insertions(+), 2 deletions(-) create mode 100644 inventory-server/src/routes/purchase-orders.js create mode 100644 inventory/src/pages/PurchaseOrders.tsx diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js new file mode 100644 index 0000000..73d2853 --- /dev/null +++ b/inventory-server/src/routes/purchase-orders.js @@ -0,0 +1,315 @@ +const express = require('express'); +const router = express.Router(); + +// Get all purchase orders with summary metrics +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; + + let whereClause = '1=1'; + const params = []; + + if (search) { + whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ?)'; + params.push(`%${search}%`, `%${search}%`); + } + + if (status && status !== 'all') { + whereClause += ' AND po.status = ?'; + params.push(status); + } + + if (vendor && vendor !== 'all') { + whereClause += ' AND po.vendor = ?'; + params.push(vendor); + } + + if (startDate) { + whereClause += ' AND po.date >= ?'; + params.push(startDate); + } + + if (endDate) { + whereClause += ' AND po.date <= ?'; + params.push(endDate); + } + + // Get total count for pagination + const [countResult] = await pool.query(` + SELECT COUNT(DISTINCT po_id) as total + FROM purchase_orders po + WHERE ${whereClause} + `, params); + + const total = countResult[0].total; + const offset = (page - 1) * limit; + const pages = Math.ceil(total / limit); + + // Get recent purchase orders + const [orders] = await pool.query(` + SELECT + po_id as id, + vendor as vendor_name, + DATE_FORMAT(date, '%Y-%m-%d') as order_date, + status, + COUNT(DISTINCT product_id) as total_items, + SUM(ordered) as total_quantity, + SUM(ordered * cost_price) as total_cost, + SUM(received) as total_received, + ROUND( + SUM(received) / SUM(ordered), 3 + ) as fulfillment_rate + FROM purchase_orders po + WHERE ${whereClause} + GROUP BY po_id, vendor, date, status + ORDER BY + CASE + WHEN ? = 'order_date' THEN date + WHEN ? = 'vendor_name' THEN vendor + WHEN ? = 'total_cost' THEN SUM(ordered * cost_price) + WHEN ? = 'total_received' THEN SUM(received) + WHEN ? = 'fulfillment_rate' THEN SUM(received) / SUM(ordered) + ELSE date + END ${sortDirection === 'desc' ? 'DESC' : 'ASC'} + LIMIT ? OFFSET ? + `, [...params, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, Number(limit), offset]); + + // Get unique vendors for filter options + const [vendors] = await pool.query(` + SELECT DISTINCT vendor + FROM purchase_orders + WHERE vendor IS NOT NULL AND vendor != '' + ORDER BY vendor + `); + + // Get unique statuses for filter options + const [statuses] = await pool.query(` + SELECT DISTINCT status + FROM purchase_orders + WHERE status IS NOT NULL AND status != '' + ORDER BY status + `); + + // Parse numeric values + const parsedOrders = orders.map(order => ({ + id: order.id, + vendor_name: order.vendor_name, + order_date: order.order_date, + status: order.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 + })); + + res.json({ + orders: parsedOrders, + pagination: { + total, + pages, + page: Number(page), + limit: Number(limit) + }, + filters: { + vendors: vendors.map(v => v.vendor), + statuses: statuses.map(s => s.status) + } + }); + } catch (error) { + console.error('Error fetching purchase orders:', error); + res.status(500).json({ error: 'Failed to fetch purchase orders' }); + } +}); + +// Get vendor performance metrics +router.get('/vendor-metrics', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const [metrics] = await pool.query(` + SELECT + vendor as vendor_name, + COUNT(DISTINCT po_id) as total_orders, + SUM(ordered) as total_ordered, + SUM(received) as total_received, + ROUND( + SUM(received) / SUM(ordered), 3 + ) as fulfillment_rate, + ROUND( + SUM(ordered * cost_price) / SUM(ordered), 2 + ) as avg_unit_cost, + SUM(ordered * cost_price) as total_spend, + ROUND(AVG( + CASE + WHEN status = 'received' AND received_date IS NOT NULL AND date IS NOT NULL + THEN DATEDIFF(received_date, date) + ELSE NULL + END + ), 1) as avg_delivery_days + FROM purchase_orders + WHERE vendor IS NOT NULL AND vendor != '' + GROUP BY vendor + HAVING total_orders > 0 + ORDER BY total_spend DESC + `); + + // Parse numeric values + const parsedMetrics = metrics.map(vendor => ({ + id: vendor.vendor_name, + vendor_name: vendor.vendor_name, + total_orders: Number(vendor.total_orders) || 0, + total_ordered: Number(vendor.total_ordered) || 0, + total_received: Number(vendor.total_received) || 0, + fulfillment_rate: Number(vendor.fulfillment_rate) || 0, + avg_unit_cost: Number(vendor.avg_unit_cost) || 0, + total_spend: Number(vendor.total_spend) || 0, + avg_delivery_days: Number(vendor.avg_delivery_days) || 0 + })); + + res.json(parsedMetrics); + } catch (error) { + console.error('Error fetching vendor metrics:', error); + res.status(500).json({ error: 'Failed to fetch vendor metrics' }); + } +}); + +// Get cost analysis +router.get('/cost-analysis', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const [analysis] = await pool.query(` + SELECT + p.categories, + COUNT(DISTINCT po.product_id) as unique_products, + ROUND(AVG(po.cost_price), 2) as avg_cost, + MIN(po.cost_price) as min_cost, + MAX(po.cost_price) as max_cost, + ROUND( + STDDEV(po.cost_price), 2 + ) as cost_variance, + SUM(po.ordered * po.cost_price) as total_spend + FROM purchase_orders po + JOIN products p ON po.product_id = p.product_id + GROUP BY p.categories + ORDER BY total_spend DESC + `); + + // Parse numeric values and add ids for React keys + const parsedAnalysis = analysis.map(item => ({ + id: item.categories || 'Uncategorized', + categories: item.categories || 'Uncategorized', + unique_products: Number(item.unique_products) || 0, + avg_cost: Number(item.avg_cost) || 0, + min_cost: Number(item.min_cost) || 0, + max_cost: Number(item.max_cost) || 0, + cost_variance: Number(item.cost_variance) || 0, + total_spend: Number(item.total_spend) || 0 + })); + + // Transform the data with parsed values + const transformedAnalysis = { + ...parsedAnalysis[0], + total_spend_by_category: parsedAnalysis.map(item => ({ + id: item.categories, + category: item.categories, + total_spend: Number(item.total_spend) + })) + }; + + res.json(transformedAnalysis); + } catch (error) { + console.error('Error fetching cost analysis:', error); + res.status(500).json({ error: 'Failed to fetch cost analysis' }); + } +}); + +// Get receiving status metrics +router.get('/receiving-status', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const [status] = await pool.query(` + WITH po_totals AS ( + SELECT + po_id, + SUM(ordered) as total_ordered, + SUM(received) as total_received, + SUM(ordered * cost_price) as total_cost + FROM purchase_orders + GROUP BY po_id + ) + SELECT + COUNT(DISTINCT po_id) as order_count, + SUM(total_ordered) as total_ordered, + SUM(total_received) as total_received, + ROUND( + SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3 + ) as fulfillment_rate, + SUM(total_cost) as total_value, + ROUND(AVG(total_cost), 2) as avg_cost + FROM po_totals + `); + + // Parse numeric values + const parsedStatus = { + order_count: Number(status[0].order_count) || 0, + total_ordered: Number(status[0].total_ordered) || 0, + total_received: Number(status[0].total_received) || 0, + fulfillment_rate: Number(status[0].fulfillment_rate) || 0, + total_value: Number(status[0].total_value) || 0, + avg_cost: Number(status[0].avg_cost) || 0 + }; + + res.json(parsedStatus); + } catch (error) { + console.error('Error fetching receiving status:', error); + res.status(500).json({ error: 'Failed to fetch receiving status' }); + } +}); + +// Get order vs received quantities by product +router.get('/order-vs-received', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const [quantities] = await pool.query(` + SELECT + p.product_id, + p.title as product, + p.SKU as sku, + SUM(po.ordered) as ordered_quantity, + SUM(po.received) as received_quantity, + ROUND( + SUM(po.received) / NULLIF(SUM(po.ordered), 0) * 100, 1 + ) 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 + WHERE po.date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) + GROUP BY p.product_id, p.title, p.SKU + HAVING order_count > 0 + ORDER BY ordered_quantity DESC + LIMIT 20 + `); + + // Parse numeric values and add id for React keys + 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) + })); + + res.json(parsedQuantities); + } catch (error) { + console.error('Error fetching order vs received quantities:', error); + res.status(500).json({ error: 'Failed to fetch order vs received quantities' }); + } +}); + +module.exports = router; \ No newline at end of file diff --git a/inventory-server/src/server.js b/inventory-server/src/server.js index fca6861..b473e14 100755 --- a/inventory-server/src/server.js +++ b/inventory-server/src/server.js @@ -9,6 +9,7 @@ const dashboardRouter = require('./routes/dashboard'); const ordersRouter = require('./routes/orders'); const csvRouter = require('./routes/csv'); const analyticsRouter = require('./routes/analytics'); +const purchaseOrdersRouter = require('./routes/purchase-orders'); // Get the absolute path to the .env file const envPath = path.resolve(process.cwd(), '.env'); @@ -79,6 +80,7 @@ app.use('/api/dashboard', dashboardRouter); app.use('/api/orders', ordersRouter); app.use('/api/csv', csvRouter); app.use('/api/analytics', analyticsRouter); +app.use('/api/purchase-orders', purchaseOrdersRouter); // Basic health check route app.get('/health', (req, res) => { diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 26e1c2e..10facb1 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -8,6 +8,7 @@ import { Orders } from './pages/Orders'; import { Settings } from './pages/Settings'; import { Analytics } from './pages/Analytics'; import { Toaster } from '@/components/ui/sonner'; +import PurchaseOrders from './pages/PurchaseOrders'; const queryClient = new QueryClient(); @@ -22,6 +23,7 @@ function App() { } /> } /> } /> + } /> } /> } /> @@ -30,5 +32,5 @@ function App() { ); } - export default App; + diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index c67f1d0..925c15b 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -1,4 +1,4 @@ -import { Home, Package, ShoppingCart, BarChart2, Settings, Plus, Box } from "lucide-react"; +import { Home, Package, ShoppingCart, BarChart2, Settings, Plus, Box, ClipboardList } from "lucide-react"; import { Sidebar, SidebarContent, @@ -30,6 +30,11 @@ const items = [ icon: ShoppingCart, url: "/orders", }, + { + title: "Purchase Orders", + icon: ClipboardList, + url: "/purchase-orders", + }, { title: "Analytics", icon: BarChart2, diff --git a/inventory/src/pages/PurchaseOrders.tsx b/inventory/src/pages/PurchaseOrders.tsx new file mode 100644 index 0000000..e1761e3 --- /dev/null +++ b/inventory/src/pages/PurchaseOrders.tsx @@ -0,0 +1,445 @@ +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 { Button } from '../components/ui/button'; +import { Input } from '../components/ui/input'; +import { Badge } from '../components/ui/badge'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '../components/ui/pagination'; + +interface PurchaseOrder { + id: number; + vendor_name: string; + order_date: string; + status: string; + total_items: number; + total_quantity: number; + total_cost: number; + total_received: number; + fulfillment_rate: number; +} + +interface VendorMetrics { + vendor_name: string; + total_orders: number; + avg_delivery_days: number; + fulfillment_rate: number; + avg_unit_cost: number; + total_spend: number; +} + +interface CostAnalysis { + unique_products: number; + avg_cost: number; + min_cost: number; + max_cost: number; + cost_variance: number; + total_spend_by_category: { + category: string; + total_spend: number; + }[]; +} + +interface ReceivingStatus { + order_count: number; + total_ordered: number; + total_received: number; + fulfillment_rate: number; + total_value: number; + avg_cost: number; +} + +interface PurchaseOrdersResponse { + orders: PurchaseOrder[]; + pagination: { + total: number; + pages: number; + page: number; + limit: number; + }; + filters: { + vendors: string[]; + statuses: string[]; + }; +} + +export default function PurchaseOrders() { + const [purchaseOrders, setPurchaseOrders] = useState([]); + const [vendorMetrics, setVendorMetrics] = useState([]); + const [costAnalysis, setCostAnalysis] = useState(null); + const [receivingStatus, setReceivingStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [sortColumn, setSortColumn] = useState('order_date'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc'); + const [filters, setFilters] = useState({ + search: '', + status: 'all', + vendor: 'all', + }); + const [filterOptions, setFilterOptions] = useState<{ + vendors: string[]; + statuses: string[]; + }>({ + vendors: [], + statuses: [], + }); + const [pagination, setPagination] = useState({ + total: 0, + pages: 0, + page: 1, + limit: 100, + }); + + const fetchData = async () => { + try { + 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 }, + }); + + const [ + purchaseOrdersRes, + vendorMetricsRes, + costAnalysisRes, + receivingStatusRes + ] = await Promise.all([ + fetch(`/api/purchase-orders?${searchParams}`), + fetch('/api/purchase-orders/vendor-metrics'), + fetch('/api/purchase-orders/cost-analysis'), + fetch('/api/purchase-orders/receiving-status') + ]); + + const [ + purchaseOrdersData, + vendorMetricsData, + costAnalysisData, + receivingStatusData + ] = await Promise.all([ + purchaseOrdersRes.json(), + vendorMetricsRes.json(), + costAnalysisRes.json(), + receivingStatusRes.json() + ]); + + setPurchaseOrders(purchaseOrdersData.orders); + setPagination(purchaseOrdersData.pagination); + setFilterOptions(purchaseOrdersData.filters); + setVendorMetrics(vendorMetricsData); + setCostAnalysis(costAnalysisData); + setReceivingStatus(receivingStatusData); + } catch (error) { + console.error('Error fetching data:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [page, sortColumn, sortDirection, filters]); + + const handleSort = (column: string) => { + if (sortColumn === column) { + setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + } else { + setSortColumn(column); + setSortDirection('asc'); + } + }; + + const getStatusBadge = (status: string) => { + const variants: Record = { + pending: { variant: "outline", label: "Pending" }, + received: { variant: "default", label: "Received" }, + partial: { variant: "secondary", label: "Partial" }, + cancelled: { variant: "destructive", label: "Cancelled" }, + }; + + const statusConfig = variants[status.toLowerCase()] || variants.pending; + return {statusConfig.label}; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+

Purchase Orders

+ + {/* Metrics Overview */} +
+ + + Total Orders + + +
{receivingStatus?.order_count || 0}
+
+
+ + + Total Value + + +
+ ${(receivingStatus?.total_value || 0).toFixed(2)} +
+
+
+ + + Fulfillment Rate + + +
+ {((receivingStatus?.fulfillment_rate || 0) * 100).toFixed(1)}% +
+
+
+ + + Avg Cost per PO + + +
+ ${(receivingStatus?.avg_cost || 0).toFixed(2)} +
+
+
+
+ + {/* Filters */} +
+
+ setFilters(prev => ({ ...prev, search: e.target.value }))} + className="h-8 w-[300px]" + /> +
+
+ + +
+
+ + {/* Purchase Orders Table */} + + + Recent Purchase Orders + + + + + + + + + + + + + + + + + + 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} + {po.total_quantity} + ${po.total_cost.toFixed(2)} + {po.total_received} + {(po.fulfillment_rate * 100).toFixed(1)}% + + ))} + {!purchaseOrders.length && ( + + + No purchase orders found + + + )} + +
+
+
+ + {/* Pagination */} + {pagination.pages > 1 && ( +
+ + + + setPage(p => Math.max(1, p - 1))} + disabled={page === 1} + /> + + {Array.from({ length: pagination.pages }, (_, i) => i + 1).map((p) => ( + + setPage(p)} + isActive={p === page} + > + {p} + + + ))} + + setPage(p => Math.min(pagination.pages, p + 1))} + disabled={page === pagination.pages} + /> + + + +
+ )} + + {/* 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} + {vendor.avg_delivery_days.toFixed(1)} + {(vendor.fulfillment_rate * 100).toFixed(1)}% + ${vendor.avg_unit_cost.toFixed(2)} + ${vendor.total_spend.toFixed(2)} + + ))} + +
+
+
+ + {/* Cost Analysis */} + + + Cost Analysis by Category + + + + + + Category + Total Spend + + + + {costAnalysis?.total_spend_by_category?.map((category) => ( + + {category.category} + ${category.total_spend.toFixed(2)} + + )) || ( + + + No cost analysis data available + + + )} + +
+
+
+
+ ); +} \ No newline at end of file