From eeff5817ea34dd26312709ab472ff290dfd19de5 Mon Sep 17 00:00:00 2001 From: Matt Date: Sun, 13 Apr 2025 22:19:14 -0400 Subject: [PATCH] More layout/header tweaks for purchase orders --- .../src/routes/purchase-orders.js | 61 + inventory/src/pages/PurchaseOrders.tsx | 1218 ++++++++++------- 2 files changed, 759 insertions(+), 520 deletions(-) diff --git a/inventory-server/src/routes/purchase-orders.js b/inventory-server/src/routes/purchase-orders.js index 5a0d2d7..7a9d17e 100644 --- a/inventory-server/src/routes/purchase-orders.js +++ b/inventory-server/src/routes/purchase-orders.js @@ -836,4 +836,65 @@ router.get('/order-vs-received', async (req, res) => { } }); +// New endpoint for delivery metrics +router.get('/delivery-metrics', async (req, res) => { + try { + const pool = req.app.locals.pool; + + const { rows: deliveryData } = await pool.query(` + WITH po_dates AS ( + SELECT + po_id, + date as order_date + FROM purchase_orders + WHERE status != 'canceled' + GROUP BY po_id, date + ), + receiving_dates AS ( + SELECT + receiving_id as po_id, + MIN(received_date) as first_received_date + FROM receivings + GROUP BY receiving_id + ), + delivery_times AS ( + SELECT + po.po_id, + po.order_date, + r.first_received_date, + CASE + WHEN r.first_received_date IS NOT NULL AND po.order_date IS NOT NULL + THEN (r.first_received_date::date - po.order_date::date) + ELSE NULL + END as delivery_days + FROM po_dates po + JOIN receiving_dates r ON po.po_id = r.po_id + WHERE + r.first_received_date IS NOT NULL + AND po.order_date IS NOT NULL + AND r.first_received_date::date >= po.order_date::date + ) + SELECT + ROUND(AVG(delivery_days)::numeric, 1) as avg_delivery_days, + ROUND(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY delivery_days)::numeric, 1) as median_delivery_days, + MIN(delivery_days) as min_delivery_days, + MAX(delivery_days) as max_delivery_days, + COUNT(*) as total_orders_with_delivery + FROM delivery_times + WHERE delivery_days >= 0 AND delivery_days <= 365 -- Filter out unreasonable values + `); + + res.json({ + avg_delivery_days: Number(deliveryData[0]?.avg_delivery_days) || 0, + median_delivery_days: Number(deliveryData[0]?.median_delivery_days) || 0, + min_delivery_days: Number(deliveryData[0]?.min_delivery_days) || 0, + max_delivery_days: Number(deliveryData[0]?.max_delivery_days) || 0, + total_orders_with_delivery: Number(deliveryData[0]?.total_orders_with_delivery) || 0 + }); + } catch (error) { + console.error('Error fetching delivery metrics:', error); + res.status(500).json({ error: 'Failed to fetch delivery metrics' }); + } +}); + module.exports = router; \ No newline at end of file diff --git a/inventory/src/pages/PurchaseOrders.tsx b/inventory/src/pages/PurchaseOrders.tsx index c882847..22c08ca 100644 --- a/inventory/src/pages/PurchaseOrders.tsx +++ b/inventory/src/pages/PurchaseOrders.tsx @@ -1,18 +1,30 @@ -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 { 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 { useEffect, useState, useRef, useMemo } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../components/ui/table"; +import { BarChart3, FileText, 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, SelectItem, SelectTrigger, SelectValue, -} from '../components/ui/select'; +} from "../components/ui/select"; import { Pagination, PaginationContent, @@ -21,13 +33,13 @@ import { PaginationLink, PaginationNext, PaginationPrevious, -} from '../components/ui/pagination'; +} from "../components/ui/pagination"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, -} from '../components/ui/tooltip'; +} from "../components/ui/tooltip"; import { Dialog, DialogContent, @@ -40,8 +52,8 @@ import { getPurchaseOrderStatusLabel, getReceivingStatusLabel, getPurchaseOrderStatusVariant, - getReceivingStatusVariant -} from '../types/status-codes'; + getReceivingStatusVariant, +} from "../types/status-codes"; import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"; interface PurchaseOrder { @@ -56,13 +68,14 @@ interface PurchaseOrder { total_received: number; fulfillment_rate: number; short_note: string | null; - record_type: 'po_only' | 'po_with_receiving' | 'receiving_only'; + record_type: "po_only" | "po_with_receiving" | "receiving_only"; } interface VendorMetrics { vendor_name: string; total_orders: number; avg_delivery_days: number; + max_delivery_days: number; fulfillment_rate: number; avg_unit_cost: number; total_spend: number; @@ -90,6 +103,8 @@ interface ReceivingStatus { fulfillment_rate: number; total_value: number; avg_cost: number; + avg_delivery_days?: number; + max_delivery_days?: number; } interface PurchaseOrdersResponse { @@ -126,17 +141,27 @@ const COLORS = [ "#FF7C43", ]; -// Add this function to render active slice of pie chart +// Replace the renderActiveShape function with one matching StockMetrics.tsx const renderActiveShape = (props: any) => { - const { cx, cy, innerRadius, outerRadius, startAngle, endAngle, fill, category, total_spend, percentage } = props; - + const { + cx, + cy, + innerRadius, + outerRadius, + startAngle, + endAngle, + fill, + category, + total_spend, + } = props; + // Split category name into words and create lines of max 12 chars - const words = category.split(' '); + const words = category.split(" "); const lines: string[] = []; - let currentLine = ''; - + let currentLine = ""; + words.forEach((word: string) => { - if ((currentLine + ' ' + word).length <= 12) { + if ((currentLine + " " + word).length <= 12) { currentLine = currentLine ? `${currentLine} ${word}` : word; } else { if (currentLine) lines.push(currentLine); @@ -166,44 +191,31 @@ const renderActiveShape = (props: any) => { fill={fill} /> {lines.map((line, i) => ( - {line} ))} - - {`$${total_spend.toLocaleString('en-US', { + {`$${Number(total_spend).toLocaleString("en-US", { minimumFractionDigits: 2, - maximumFractionDigits: 2 + maximumFractionDigits: 2, })}`} - - {`${(percentage * 100).toLocaleString('en-US', { - minimumFractionDigits: 1, - maximumFractionDigits: 1 - })}%`} - ); }; @@ -211,24 +223,24 @@ const renderActiveShape = (props: any) => { export default function PurchaseOrders() { const [purchaseOrders, setPurchaseOrders] = useState([]); const [, setVendorMetrics] = useState([]); - const [costAnalysis, setCostAnalysis] = useState(null); + const [, setCostAnalysis] = useState(null); const [summary, setSummary] = 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', - recordType: 'all', + const [sortColumn, setSortColumn] = useState("order_date"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc"); + const [filterValues, setFilterValues] = useState({ + search: "", + status: "all", + vendor: "all", + recordType: "all", }); const [filterOptions, setFilterOptions] = useState<{ vendors: string[]; statuses: number[]; }>({ vendors: [], - statuses: [] + statuses: [], }); const [pagination, setPagination] = useState({ total: 0, @@ -237,67 +249,103 @@ 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 [activeSpendingIndex, setActiveSpendingIndex] = useState< + number | undefined + >(); + const [activeVendorIndex, setActiveVendorIndex] = useState< + number | undefined + >(); 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 [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 hasInitialFetchRef = useRef(false); + const hasInitialYearlyFetchRef = useRef(false); const STATUS_FILTER_OPTIONS = [ - { value: 'all', label: 'All Statuses' }, - { value: String(PurchaseOrderStatus.Created), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created) }, - { value: String(PurchaseOrderStatus.ElectronicallyReadySend), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ElectronicallyReadySend) }, - { value: String(PurchaseOrderStatus.Ordered), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered) }, - { value: String(PurchaseOrderStatus.ReceivingStarted), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted) }, - { value: String(PurchaseOrderStatus.Done), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done) }, - { value: String(PurchaseOrderStatus.Canceled), label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Canceled) }, + { value: "all", label: "All Statuses" }, + { + value: String(PurchaseOrderStatus.Created), + label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Created), + }, + { + value: String(PurchaseOrderStatus.ElectronicallyReadySend), + label: getPurchaseOrderStatusLabel( + PurchaseOrderStatus.ElectronicallyReadySend + ), + }, + { + value: String(PurchaseOrderStatus.Ordered), + label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Ordered), + }, + { + value: String(PurchaseOrderStatus.ReceivingStarted), + label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.ReceivingStarted), + }, + { + value: String(PurchaseOrderStatus.Done), + label: getPurchaseOrderStatusLabel(PurchaseOrderStatus.Done), + }, + { + 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' }, + { 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 filters = useMemo(() => filterValues, [filterValues]); + const fetchData = async () => { + if ( + hasInitialFetchRef.current && + import.meta.hot && + purchaseOrders.length > 0 + ) { + return; + } + try { setLoading(true); const searchParams = new URLSearchParams({ page: page.toString(), - limit: '100', + limit: "100", sortColumn, sortDirection, - ...filters.search && { search: filters.search }, - ...filters.status !== 'all' && { status: filters.status }, - ...filters.vendor !== 'all' && { vendor: filters.vendor }, - ...filters.recordType !== 'all' && { recordType: filters.recordType }, + ...(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, - costAnalysisRes - ] = await Promise.all([ - fetch(`/api/purchase-orders?${searchParams}`), - fetch('/api/purchase-orders/vendor-metrics'), - fetch('/api/purchase-orders/cost-analysis') - ]); + + const [purchaseOrdersRes, vendorMetricsRes, costAnalysisRes, deliveryMetricsRes] = + await Promise.all([ + fetch(`/api/purchase-orders?${searchParams}`), + fetch("/api/purchase-orders/vendor-metrics"), + fetch("/api/purchase-orders/cost-analysis"), + fetch("/api/purchase-orders/delivery-metrics"), + ]); // Initialize default data let purchaseOrdersData: PurchaseOrdersResponse = { @@ -308,18 +356,18 @@ export default function PurchaseOrders() { total_received: 0, fulfillment_rate: 0, total_value: 0, - avg_cost: 0 + avg_cost: 0, }, pagination: { total: 0, pages: 0, page: 1, - limit: 100 + limit: 100, }, filters: { vendors: [], - statuses: [] - } + statuses: [], + }, }; let vendorMetricsData: VendorMetrics[] = []; @@ -329,30 +377,53 @@ export default function PurchaseOrders() { min_cost: 0, max_cost: 0, cost_variance: 0, - total_spend_by_category: [] + total_spend_by_category: [], + }; + + let deliveryMetricsData = { + avg_delivery_days: 0, + max_delivery_days: 0 }; // Only try to parse responses if they were successful if (purchaseOrdersRes.ok) { purchaseOrdersData = await purchaseOrdersRes.json(); } else { - console.error('Failed to fetch purchase orders:', await purchaseOrdersRes.text()); + console.error( + "Failed to fetch purchase orders:", + await purchaseOrdersRes.text() + ); } if (vendorMetricsRes.ok) { vendorMetricsData = await vendorMetricsRes.json(); } else { - console.error('Failed to fetch vendor metrics:', await vendorMetricsRes.text()); + console.error( + "Failed to fetch vendor metrics:", + await vendorMetricsRes.text() + ); } if (costAnalysisRes.ok) { costAnalysisData = await costAnalysisRes.json(); } else { - console.error('Failed to fetch cost analysis:', await costAnalysisRes.text()); + console.error( + "Failed to fetch cost analysis:", + await costAnalysisRes.text() + ); + } + + if (deliveryMetricsRes.ok) { + deliveryMetricsData = await deliveryMetricsRes.json(); + } else { + console.error( + "Failed to fetch delivery metrics:", + await deliveryMetricsRes.text() + ); } // Process orders data - const processedOrders = purchaseOrdersData.orders.map(order => { + const processedOrders = purchaseOrdersData.orders.map((order) => { let processedOrder = { ...order, status: Number(order.status), @@ -360,20 +431,30 @@ export default function PurchaseOrders() { 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 + fulfillment_rate: Number(order.fulfillment_rate) || 0, }; - + return processedOrder; }); + // Merge delivery metrics into summary + const summaryWithDelivery = { + ...purchaseOrdersData.summary, + avg_delivery_days: deliveryMetricsData.avg_delivery_days, + max_delivery_days: deliveryMetricsData.max_delivery_days + }; + setPurchaseOrders(processedOrders); setPagination(purchaseOrdersData.pagination); setFilterOptions(purchaseOrdersData.filters); - setSummary(purchaseOrdersData.summary); + setSummary(summaryWithDelivery); setVendorMetrics(vendorMetricsData); setCostAnalysis(costAnalysisData); + + // Mark that we've completed an initial fetch + hasInitialFetchRef.current = true; } catch (error) { - console.error('Error fetching data:', error); + console.error("Error fetching data:", error); // Set default values in case of error setPurchaseOrders([]); setPagination({ total: 0, pages: 0, page: 1, limit: 100 }); @@ -384,7 +465,7 @@ export default function PurchaseOrders() { total_received: 0, fulfillment_rate: 0, total_value: 0, - avg_cost: 0 + avg_cost: 0, }); setVendorMetrics([]); setCostAnalysis({ @@ -393,7 +474,7 @@ export default function PurchaseOrders() { min_cost: 0, max_cost: 0, cost_variance: 0, - total_spend_by_category: [] + total_spend_by_category: [], }); } finally { setLoading(false); @@ -402,33 +483,44 @@ export default function PurchaseOrders() { useEffect(() => { fetchData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, sortColumn, sortDirection, filters]); const handleSort = (column: string) => { if (sortColumn === column) { - setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc'); + setSortDirection((prev) => (prev === "asc" ? "desc" : "asc")); } else { setSortColumn(column); - setSortDirection('asc'); + setSortDirection("asc"); } }; const getStatusBadge = (status: number, recordType: string) => { - if (recordType === 'receiving_only') { - return - {getReceivingStatusLabel(status)} - ; + if (recordType === "receiving_only") { + return ( + + {getReceivingStatusLabel(status)} + + ); } - - return - {getPurchaseOrderStatusLabel(status)} - ; + + return ( + + {getPurchaseOrderStatusLabel(status)} + + ); }; const formatNumber = (value: number) => { - return value.toLocaleString('en-US', { + return value.toLocaleString("en-US", { minimumFractionDigits: 2, - maximumFractionDigits: 2 + maximumFractionDigits: 2, }); }; @@ -437,23 +529,25 @@ export default function PurchaseOrders() { }; const formatPercent = (value: number) => { - return (value * 100).toLocaleString('en-US', { - minimumFractionDigits: 1, - maximumFractionDigits: 1 - }) + '%'; + return ( + (value * 100).toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + "%" + ); }; // Generate pagination items const getPaginationItems = () => { const items = []; const totalPages = pagination.pages; - + // Always show first page if (totalPages > 0) { items.push( - page !== 1 && setPage(1)} > 1 @@ -479,8 +573,8 @@ export default function PurchaseOrders() { if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately items.push( - page !== i && setPage(i)} > {i} @@ -502,8 +596,8 @@ export default function PurchaseOrders() { if (totalPages > 1) { items.push( - page !== totalPages && setPage(totalPages)} > {totalPages} @@ -517,46 +611,73 @@ export default function PurchaseOrders() { // Update this function to fetch yearly data const fetchYearlyData = async () => { + if ( + hasInitialYearlyFetchRef.current && + import.meta.hot && + (yearlyVendorData.length > 0 || yearlyCategoryData.length > 0) + ) { + return; + } + 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 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}`) + 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 - }))); + 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()); + 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 - }))); + 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()); + console.error( + "Failed to fetch yearly category data:", + await categoryResponse.text() + ); } + + // Mark that we've completed an initial fetch + hasInitialYearlyFetchRef.current = true; } catch (error) { - console.error('Error fetching yearly data:', error); + console.error("Error fetching yearly data:", error); } finally { setYearlyDataLoading(false); } @@ -565,6 +686,7 @@ export default function PurchaseOrders() { // Fetch yearly data when component mounts, not just when dialogs open useEffect(() => { fetchYearlyData(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Update the CostAnalysisTable to always show yearly data @@ -580,7 +702,7 @@ export default function PurchaseOrders() { ); } - + return (
{yearlyDataLoading ? ( @@ -590,7 +712,9 @@ export default function PurchaseOrders() { ) : ( <>
- Showing received inventory by category for the past 12 months + + Showing received inventory by category for the past 12 months + {yearlyCategoryData.length} categories found
@@ -607,27 +731,40 @@ export default function PurchaseOrders() { {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); - + 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.category || "Uncategorized"} {category.unique_products?.toLocaleString() || "N/A"} - {category.avg_cost !== undefined ? formatCurrency(category.avg_cost) : "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"} + {category.cost_variance !== undefined + ? parseFloat( + category.cost_variance.toFixed(2) + ).toLocaleString() + : "N/A"} {formatCurrency(category.total_spend)} @@ -649,28 +786,40 @@ export default function PurchaseOrders() { // Display a record type indicator with appropriate styling const getRecordTypeIndicator = (recordType: string) => { switch (recordType) { - case 'po_with_receiving': + case "po_with_receiving": return ( - + Received PO ); - case 'po_only': + case "po_only": return ( - + PO ); - case 'receiving_only': + case "receiving_only": return ( - + Receiving ); default: return ( - - {recordType || 'Unknown'} + + {recordType || "Unknown"} ); } @@ -680,40 +829,50 @@ export default function PurchaseOrders() { 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); - + 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); - + 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 + (sum, cat) => sum + cat.total_spend, + 0 ); - + result.push({ - category: 'Other', + category: "Other", total_spend: otherTotalSpend, percentage: otherTotalSpend / totalSpend, unique_products: otherCategories.reduce( - (sum, cat) => sum + (cat.unique_products || 0), 0 + (sum, cat) => sum + (cat.unique_products || 0), + 0 ), - avg_cost: otherTotalSpend / otherCategories.reduce( - (sum, cat) => sum + (cat.unique_products || 0), 1 - ), - cost_variance: 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); }; @@ -722,34 +881,40 @@ export default function PurchaseOrders() { 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); - + 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); - + 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 + (sum, vendor) => sum + vendor.total_spend, + 0 ); - + result.push({ - vendor: 'Other Vendors', + vendor: "Other Vendors", total_spend: otherTotalSpend, percentage: otherTotalSpend / totalSpend, - orders: otherVendors.reduce((sum, vendor) => sum + vendor.orders, 0) + 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); }; @@ -758,14 +923,14 @@ export default function PurchaseOrders() { 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 ? (
@@ -777,7 +942,7 @@ export default function PurchaseOrders() {
); } - + return (
{yearlyDataLoading ? ( @@ -787,7 +952,9 @@ export default function PurchaseOrders() { ) : ( <>
- Showing received inventory by vendor for the past 12 months + + Showing received inventory by vendor for the past 12 months + {vendorData.length} vendors found
@@ -807,9 +974,7 @@ export default function PurchaseOrders() { {vendor.vendor} - - {vendor.orders.toLocaleString()} - + {vendor.orders.toLocaleString()} {formatCurrency(vendor.total_spend)} @@ -817,7 +982,9 @@ export default function PurchaseOrders() { {formatPercent(vendor.percentage || 0)} - {formatCurrency(vendor.orders ? vendor.total_spend / vendor.orders : 0)} + {formatCurrency( + vendor.orders ? vendor.total_spend / vendor.orders : 0 + )} ); @@ -832,7 +999,7 @@ export default function PurchaseOrders() { // Function to render the revised metrics cards const renderMetricsCards = () => ( -
+
{/* Combined Metrics Card */} @@ -845,225 +1012,87 @@ export default function PurchaseOrders() { ) : ( -
+
-

Avg. Cost per PO

+

+ Avg. Cost per PO +

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

-

Fulfillment Rate

+

+ Overall Fulfillment Rate +

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

-

Total Orders

-

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

+

+ Total Orders +

+

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

+
+

+ Avg. Delivery Days +

+

+ {summary?.avg_delivery_days ? summary.avg_delivery_days.toFixed(1) : "N/A"} +

+
+
+

+ Longest Delivery Days +

+

+ {summary?.max_delivery_days ? summary.max_delivery_days.toFixed(0) : "N/A"} +

+
+
)} - - {/* 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 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})} + activeShape={(props: any) => + renderActiveShape({ ...props, category: props.vendor }) + } onMouseEnter={(_, index) => setActiveVendorIndex(index)} onMouseLeave={() => setActiveVendorIndex(undefined)} > {prepareVendorChartData().map((entry, index) => ( - @@ -1090,30 +1121,68 @@ export default function PurchaseOrders() {
- - - - - - - - - - Received Inventory by Vendor - - -
- -
-
-
+ + )} + + + + {/* Category Spending Chart Card */} + + + + Received by Category + + + + + + + + + + Received Inventory by Category + + +
+ +
+
+
+
+ + {loading ? ( + + ) : ( + <> +
+ + + setActiveSpendingIndex(index)} + onMouseLeave={() => setActiveSpendingIndex(undefined)} + > + {prepareSpendingChartData().map((entry, index) => ( + + ))} + + + +
)}
@@ -1132,21 +1201,25 @@ export default function PurchaseOrders() {
setFilters(prev => ({ ...prev, search: e.target.value }))} + value={filterValues.search} + onChange={(e) => + setFilterValues((prev) => ({ ...prev, search: e.target.value })) + } className="max-w-xs" disabled={loading} />
+
- - - Type - - - - - Rec'd Date - - - Notes - Total Items - Total Quantity - - - Received - - @@ -1245,78 +1357,140 @@ export default function PurchaseOrders() { {loading ? ( // Skeleton rows for loading state - Array(50).fill(0).map((_, index) => ( - - - - - - - - - - - - - - - )) + 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'; + 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.id} {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)} - + + {getStatusBadge(po.status, po.record_type)} + + {po.short_note ? ( - {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)} + {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)}
); }) ) : ( - + No purchase orders found @@ -1332,8 +1506,8 @@ export default function PurchaseOrders() { - { e.preventDefault(); if (page > 1) setPage(page - 1); @@ -1342,18 +1516,22 @@ export default function PurchaseOrders() { 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" : ""} + className={ + page === pagination.pages + ? "pointer-events-none opacity-50" + : "" + } /> @@ -1362,4 +1540,4 @@ export default function PurchaseOrders() { )} ); -} \ No newline at end of file +}