From 4ed734e5c06564b0054307c76c9af677cd9393c5 Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 14 Apr 2025 00:58:55 -0400 Subject: [PATCH] Add PO details accordion to purchase orders page --- .../src/routes/purchase-orders.js | 158 +++++++++++++ .../PurchaseOrderAccordion.tsx | 211 ++++++++++++++++++ .../purchase-orders/PurchaseOrdersTable.tsx | 140 ++++++------ 3 files changed, 440 insertions(+), 69 deletions(-) create mode 100644 inventory/src/components/purchase-orders/PurchaseOrderAccordion.tsx diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js index f0d611c..0c272c8 100644 --- a/inventory-server/src/routes/purchase-orders.js +++ b/inventory-server/src/routes/purchase-orders.js @@ -966,6 +966,164 @@ router.get('/order-vs-received', async (req, res) => { } }); +// Get purchase order items +router.get('/:id/items', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { id } = req.params; + + if (!id) { + return res.status(400).json({ error: 'Purchase order ID is required' }); + } + + // Query to get purchase order items with product details + const { rows: items } = await pool.query(` + WITH po_items AS ( + SELECT + po.po_id, + po.pid, + po.sku, + COALESCE(po.name, p.title) as product_name, + po.po_cost_price, + po.ordered, + po.status + FROM purchase_orders po + LEFT JOIN products p ON po.pid = p.pid + WHERE po.po_id = $1 + ), + receiving_items AS ( + SELECT + r.receiving_id, + r.pid, + r.sku, + SUM(r.qty_each) as received + FROM receivings r + WHERE r.receiving_id = $1 + GROUP BY r.receiving_id, r.pid, r.sku + ) + SELECT + pi.po_id as id, + pi.pid, + pi.sku, + pi.product_name, + p.barcode, + pi.po_cost_price, + pi.ordered, + COALESCE(ri.received, 0) as received, + ROUND(pi.ordered * pi.po_cost_price, 2) as total_cost, + CASE + WHEN ri.received IS NULL THEN 'Not Received' + WHEN ri.received = 0 THEN 'Not Received' + WHEN ri.received < pi.ordered THEN 'Partially Received' + WHEN ri.received >= pi.ordered THEN 'Fully Received' + END as receiving_status + FROM po_items pi + LEFT JOIN receiving_items ri ON pi.pid = ri.pid AND pi.sku = ri.sku + LEFT JOIN products p ON pi.pid = p.pid + ORDER BY pi.product_name + `, [id]); + + // Parse numeric values + const parsedItems = items.map(item => ({ + id: `${item.id}_${item.pid}`, + pid: item.pid, + product_name: item.product_name, + sku: item.sku, + upc: item.barcode || 'N/A', + ordered: Number(item.ordered) || 0, + received: Number(item.received) || 0, + po_cost_price: Number(item.po_cost_price) || 0, + total_cost: Number(item.total_cost) || 0, + receiving_status: item.receiving_status + })); + + res.json(parsedItems); + } catch (error) { + console.error('Error fetching purchase order items:', error); + res.status(500).json({ error: 'Failed to fetch purchase order items', details: error.message }); + } +}); + +// Get receiving items +router.get('/receiving/:id/items', async (req, res) => { + try { + const pool = req.app.locals.pool; + const { id } = req.params; + + if (!id) { + return res.status(400).json({ error: 'Receiving ID is required' }); + } + + // Query to get receiving items with related PO information if available + const { rows: items } = await pool.query(` + WITH receiving_items AS ( + SELECT + r.receiving_id, + r.pid, + r.sku, + COALESCE(r.name, p.title) as product_name, + r.cost_each, + r.qty_each, + r.status + FROM receivings r + LEFT JOIN products p ON r.pid = p.pid + WHERE r.receiving_id = $1 + ), + po_items AS ( + SELECT + po.po_id, + po.pid, + po.sku, + po.ordered, + po.po_cost_price + FROM purchase_orders po + WHERE po.po_id = $1 + ) + SELECT + ri.receiving_id as id, + ri.pid, + ri.sku, + ri.product_name, + p.barcode, + COALESCE(po.ordered, 0) as ordered, + ri.qty_each as received, + COALESCE(po.po_cost_price, ri.cost_each) as po_cost_price, + ri.cost_each, + ROUND(ri.qty_each * ri.cost_each, 2) as total_cost, + CASE + WHEN po.ordered IS NULL THEN 'Receiving Only' + WHEN ri.qty_each < po.ordered THEN 'Partially Received' + WHEN ri.qty_each >= po.ordered THEN 'Fully Received' + END as receiving_status + FROM receiving_items ri + LEFT JOIN po_items po ON ri.pid = po.pid AND ri.sku = po.sku + LEFT JOIN products p ON ri.pid = p.pid + ORDER BY ri.product_name + `, [id]); + + // Parse numeric values + const parsedItems = items.map(item => ({ + id: `${item.id}_${item.pid}`, + pid: item.pid, + product_name: item.product_name, + sku: item.sku, + upc: item.barcode || 'N/A', + ordered: Number(item.ordered) || 0, + received: Number(item.received) || 0, + po_cost_price: Number(item.po_cost_price) || 0, + cost_each: Number(item.cost_each) || 0, + qty_each: Number(item.received) || 0, + total_cost: Number(item.total_cost) || 0, + receiving_status: item.receiving_status + })); + + res.json(parsedItems); + } catch (error) { + console.error('Error fetching receiving items:', error); + res.status(500).json({ error: 'Failed to fetch receiving items', details: error.message }); + } +}); + // New endpoint for delivery metrics router.get('/delivery-metrics', async (req, res) => { try { diff --git a/inventory/src/components/purchase-orders/PurchaseOrderAccordion.tsx b/inventory/src/components/purchase-orders/PurchaseOrderAccordion.tsx new file mode 100644 index 0000000..5db3c1c --- /dev/null +++ b/inventory/src/components/purchase-orders/PurchaseOrderAccordion.tsx @@ -0,0 +1,211 @@ +import React, { useState, useEffect } from "react"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { Skeleton } from "../ui/skeleton"; + +// Define the structure of purchase order items +interface PurchaseOrderItem { + id: string | number; + pid: string | number; + product_name: string; + sku: string; + upc: string; + ordered: number; + received: number; + po_cost_price: number; + cost_each?: number; // For receiving items + qty_each?: number; // For receiving items + total_cost: number; + receiving_status?: string; +} + +interface PurchaseOrder { + id: number | string; + vendor_name: 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 PurchaseOrderAccordionProps { + purchaseOrder: PurchaseOrder; + children: React.ReactNode; + rowClassName?: string; +} + +export default function PurchaseOrderAccordion({ + purchaseOrder, + children, + rowClassName, +}: PurchaseOrderAccordionProps) { + const [isOpen, setIsOpen] = useState(false); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Clone the TableRow (children) and add the onClick handler and className + const enhancedRow = React.cloneElement(children as React.ReactElement, { + onClick: () => setIsOpen(!isOpen), + className: `${(children as React.ReactElement).props.className || ""} cursor-pointer ${isOpen ? 'bg-gray-100' : ''} ${rowClassName || ""}`.trim(), + "data-state": isOpen ? "open" : "closed" + }); + + // Format currency + const formatCurrency = (value: number) => { + return `$${value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`; + }; + + useEffect(() => { + // Only fetch items when the accordion is open + if (!isOpen) return; + + const fetchItems = async () => { + setLoading(true); + setError(null); + + try { + // Endpoint path will depend on the type of record + const endpoint = purchaseOrder.record_type === "receiving_only" + ? `/api/purchase-orders/receiving/${purchaseOrder.id}/items` + : `/api/purchase-orders/${purchaseOrder.id}/items`; + + const response = await fetch(endpoint); + + if (!response.ok) { + throw new Error(`Failed to fetch items: ${response.statusText}`); + } + + const data = await response.json(); + setItems(data); + } catch (err) { + console.error("Error fetching purchase order items:", err); + setError(err instanceof Error ? err.message : "Unknown error occurred"); + } finally { + setLoading(false); + } + }; + + fetchItems(); + }, [purchaseOrder.id, purchaseOrder.record_type, isOpen]); + + // Render purchase order items list + const renderItemsList = () => { + if (error) { + return ( +
+ Error loading items: {error} +
+ ); + } + + return ( +
+ + + + SKU + Product + UPC + Ordered + Received + Unit Cost + Total Cost + {purchaseOrder.record_type !== "po_only" && ( + Status + )} + + + + {loading ? ( + // Loading skeleton + Array(5).fill(0).map((_, i) => ( + + + + + + + + + {purchaseOrder.record_type !== "po_only" && ( + + )} + + )) + ) : ( + items.map((item) => ( + + {item.sku} + {item.product_name} + {item.upc} + + {item.ordered} + + + {item.received || 0} + + + {formatCurrency(item.po_cost_price || item.cost_each || 0)} + + + {formatCurrency(item.total_cost)} + + {purchaseOrder.record_type !== "po_only" && ( + + {item.receiving_status || "Unknown"} + + )} + + )) + )} + + {!loading && items.length === 0 && ( + + + No items found for this order + + + )} + +
+
+ ); + }; + + return ( + <> + {/* First render the row which will serve as the trigger */} + {enhancedRow} + + {/* Then render the accordion content conditionally if open */} + {isOpen && ( + + +
+
+ {purchaseOrder.total_items} product{purchaseOrder.total_items !== 1 ? "s" : ""} in this {purchaseOrder.record_type === "receiving_only" ? "receiving" : "purchase order"} +
+ {renderItemsList()} +
+
+
+ )} + + ); +} \ No newline at end of file diff --git a/inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx b/inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx index 89a5065..da11181 100644 --- a/inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx +++ b/inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx @@ -28,6 +28,7 @@ import { CardHeader, CardTitle, } from "../ui/card"; +import PurchaseOrderAccordion from "./PurchaseOrderAccordion"; interface PurchaseOrder { id: number | string; @@ -332,77 +333,78 @@ export default function PurchaseOrdersTable({ rowClassName = "border-l-4 border-l-amber-500"; } return ( - - - {getRecordTypeIndicator(po.record_type)} - - {po.id} - {po.vendor_name} - - {getStatusBadge(po.status, po.record_type)} - - - {po.short_note ? ( - - - - - - {po.short_note} - - - -

{po.short_note}

-
-
-
- ) : ( - "" - )} -
- {formatCurrency(po.total_cost)} - {po.total_items.toLocaleString()} - - {po.order_date - ? new Date(po.order_date).toLocaleDateString( - "en-US", - { - month: "numeric", - day: "numeric", - year: "numeric", - } - ) - : ""} - - - {po.receiving_date - ? new Date(po.receiving_date).toLocaleDateString( - "en-US", - { - month: "numeric", - day: "numeric", - year: "numeric", - } - ) - : ""} - - - - {po.total_quantity.toLocaleString()} - - - - {po.total_received.toLocaleString()} - - - {po.fulfillment_rate === null - ? "N/A" - : formatPercent(po.fulfillment_rate)} - -
+ + + {getRecordTypeIndicator(po.record_type)} + + {po.id} + {po.vendor_name} + + {getStatusBadge(po.status, po.record_type)} + + + {po.short_note ? ( + + + + + + {po.short_note} + + + +

{po.short_note}

+
+
+
+ ) : ( + "" + )} +
+ {formatCurrency(po.total_cost)} + {po.total_items.toLocaleString()} + + {po.order_date + ? new Date(po.order_date).toLocaleDateString( + "en-US", + { + month: "numeric", + day: "numeric", + year: "numeric", + } + ) + : ""} + + + {po.receiving_date + ? new Date(po.receiving_date).toLocaleDateString( + "en-US", + { + month: "numeric", + day: "numeric", + year: "numeric", + } + ) + : ""} + + + {po.total_quantity.toLocaleString()} + + + {po.total_received.toLocaleString()} + + + {po.fulfillment_rate === null + ? "N/A" + : formatPercent(po.fulfillment_rate)} + +
+ ); }) ) : (