From 1e3be5d4cb8ad18e61d73e97e2f79d66c962165d Mon Sep 17 00:00:00 2001 From: Matt Date: Mon, 14 Apr 2025 00:29:37 -0400 Subject: [PATCH] Refactor purchase orders page into individual components --- .../purchase-orders/CategoryMetricsCard.tsx | 383 +++++ .../purchase-orders/FilterControls.tsx | 155 ++ .../purchase-orders/OrderMetricsCard.tsx | 122 ++ .../purchase-orders/PaginationControls.tsx | 140 ++ .../purchase-orders/PurchaseOrdersTable.tsx | 423 ++++++ .../purchase-orders/VendorMetricsCard.tsx | 354 +++++ inventory/src/pages/PurchaseOrders.tsx | 1256 +---------------- 7 files changed, 1625 insertions(+), 1208 deletions(-) create mode 100644 inventory/src/components/purchase-orders/CategoryMetricsCard.tsx create mode 100644 inventory/src/components/purchase-orders/FilterControls.tsx create mode 100644 inventory/src/components/purchase-orders/OrderMetricsCard.tsx create mode 100644 inventory/src/components/purchase-orders/PaginationControls.tsx create mode 100644 inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx create mode 100644 inventory/src/components/purchase-orders/VendorMetricsCard.tsx diff --git a/inventory/src/components/purchase-orders/CategoryMetricsCard.tsx b/inventory/src/components/purchase-orders/CategoryMetricsCard.tsx new file mode 100644 index 0000000..5c9a5dd --- /dev/null +++ b/inventory/src/components/purchase-orders/CategoryMetricsCard.tsx @@ -0,0 +1,383 @@ +import { useState, useEffect } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Skeleton } from "../../components/ui/skeleton"; +import { BarChart3, Loader2 } from "lucide-react"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../components/ui/dialog"; +import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; + +// Add this constant for pie chart colors +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FFC658", + "#FF7C43", +]; + +// The renderActiveShape function for pie charts +const renderActiveShape = (props: any) => { + 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 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} + + ))} + + {`$${Number(total_spend).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`} + + + ); +}; + +interface CategoryMetricsCardProps { + loading: boolean; + yearlyCategoryData: { + category: string; + unique_products?: number; + total_spend: number; + percentage?: number; + avg_cost?: number; + cost_variance?: number; + }[]; + yearlyDataLoading: boolean; +} + +export default function CategoryMetricsCard({ + loading, + yearlyCategoryData, + yearlyDataLoading, +}: CategoryMetricsCardProps) { + const [costAnalysisOpen, setCostAnalysisOpen] = useState(false); + const [activeSpendingIndex, setActiveSpendingIndex] = useState< + number | undefined + >(); + const [initialLoading, setInitialLoading] = useState(true); + + // Only show loading state on initial load, not during table refreshes + useEffect(() => { + if (yearlyCategoryData.length > 0 && !yearlyDataLoading) { + setInitialLoading(false); + } + }, [yearlyCategoryData, yearlyDataLoading]); + + const formatNumber = (value: number) => { + return value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }; + + const formatCurrency = (value: number) => { + return `$${formatNumber(value)}`; + }; + + const formatPercent = (value: number) => { + return ( + (value * 100).toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + "%" + ); + }; + + // Prepare spending chart data + const prepareSpendingChartData = () => { + 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); + }; + + // Cost analysis table component + const CostAnalysisTable = () => { + if (!yearlyCategoryData.length) { + return yearlyDataLoading ? ( +
+ +
+ ) : ( +
+ No category data available for the past 12 months +
+ ); + } + + return ( +
+ {yearlyDataLoading ? ( +
+ +
+ ) : ( + <> +
+ + Showing received inventory by category for the past 12 months + + {yearlyCategoryData.length} categories found +
+
+ Note: items can be in multiple categories, so the sum of the + categories will not equal the total spend. +
+ + + + Category + Products + Avg. Cost + Price Variance + Total Spend + % of Total + + + + {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)} + + + ); + })} + +
+ + )} +
+ ); + }; + + return ( + + + + Received by Category + + + + + + + + + + Received Inventory by Category + + +
+ +
+
+
+
+ + {initialLoading || loading ? ( +
+ +
+ ) : ( + <> +
+ + + setActiveSpendingIndex(index)} + onMouseLeave={() => setActiveSpendingIndex(undefined)} + > + {prepareSpendingChartData().map((entry, index) => ( + + ))} + + + +
+ + )} +
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/purchase-orders/FilterControls.tsx b/inventory/src/components/purchase-orders/FilterControls.tsx new file mode 100644 index 0000000..447e20e --- /dev/null +++ b/inventory/src/components/purchase-orders/FilterControls.tsx @@ -0,0 +1,155 @@ +import { Input } from "../../components/ui/input"; +import { Button } from "../../components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../../components/ui/select"; +import { + PurchaseOrderStatus, + getPurchaseOrderStatusLabel +} from "../../types/status-codes"; + +interface FilterControlsProps { + searchInput: string; + setSearchInput: (value: string) => void; + filterValues: { + search: string; + status: string; + vendor: string; + recordType: string; + }; + handleStatusChange: (value: string) => void; + handleVendorChange: (value: string) => void; + handleRecordTypeChange: (value: string) => void; + clearFilters: () => void; + filterOptions: { + vendors: string[]; + statuses: number[]; + }; + loading: boolean; +} + +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), + }, +]; + +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" }, +]; + +export default function FilterControls({ + searchInput, + setSearchInput, + filterValues, + handleStatusChange, + handleVendorChange, + handleRecordTypeChange, + clearFilters, + filterOptions, + loading, +}: FilterControlsProps) { + return ( +
+ setSearchInput(e.target.value)} + className="max-w-xs" + disabled={loading} + /> + + + + {(filterValues.search || filterValues.status !== "all" || filterValues.vendor !== "all" || filterValues.recordType !== "all") && ( + + )} +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/purchase-orders/OrderMetricsCard.tsx b/inventory/src/components/purchase-orders/OrderMetricsCard.tsx new file mode 100644 index 0000000..f042007 --- /dev/null +++ b/inventory/src/components/purchase-orders/OrderMetricsCard.tsx @@ -0,0 +1,122 @@ +import { useState, useEffect } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Skeleton } from "../../components/ui/skeleton"; + +type ReceivingStatus = { + order_count: number; + total_ordered: number; + total_received: number; + fulfillment_rate: number; + total_value: number; + avg_cost: number; + avg_delivery_days?: number; + max_delivery_days?: number; +}; + +interface OrderMetricsCardProps { + summary: ReceivingStatus | null; + loading: boolean; +} + +export default function OrderMetricsCard({ + summary, + loading, +}: OrderMetricsCardProps) { + const [initialLoading, setInitialLoading] = useState(true); + + // Only show loading state on initial load, not during table refreshes + useEffect(() => { + if (summary) { + setInitialLoading(false); + } + }, [summary]); + + const formatNumber = (value: number) => { + return value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }; + + const formatCurrency = (value: number) => { + return `$${formatNumber(value)}`; + }; + + const formatPercent = (value: number) => { + return ( + (value * 100).toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + "%" + ); + }; + + return ( + + + Order Metrics + + + {initialLoading || loading ? ( +
+ {/* 5 rows of skeleton metrics */} + {[...Array(5)].map((_, i) => ( +
+ + +
+ ))} +
+ ) : ( +
+
+

+ Avg. Cost per PO +

+

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

+
+
+

+ Overall Fulfillment Rate +

+

+ {formatPercent(summary?.fulfillment_rate || 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"} +

+
+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/purchase-orders/PaginationControls.tsx b/inventory/src/components/purchase-orders/PaginationControls.tsx new file mode 100644 index 0000000..15cc20a --- /dev/null +++ b/inventory/src/components/purchase-orders/PaginationControls.tsx @@ -0,0 +1,140 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "../../components/ui/pagination"; + +interface PaginationControlsProps { + pagination: { + total: number; + pages: number; + page: number; + limit: number; + }; + currentPage: number; + onPageChange: (page: number) => void; +} + +export default function PaginationControls({ + pagination, + currentPage, + onPageChange, +}: PaginationControlsProps) { + // Generate pagination items + const getPaginationItems = () => { + const items = []; + const totalPages = pagination.pages; + + // Always show first page + if (totalPages > 0) { + items.push( + + currentPage !== 1 && onPageChange(1)} + > + 1 + + + ); + } + + // Add ellipsis if needed + if (currentPage > 3) { + items.push( + + + + ); + } + + // Add pages around current page + const startPage = Math.max(2, currentPage - 1); + const endPage = Math.min(totalPages - 1, currentPage + 1); + + for (let i = startPage; i <= endPage; i++) { + if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately + items.push( + + currentPage !== i && onPageChange(i)} + > + {i} + + + ); + } + + // Add ellipsis if needed + if (currentPage < totalPages - 2) { + items.push( + + + + ); + } + + // Always show last page if there are multiple pages + if (totalPages > 1) { + items.push( + + currentPage !== totalPages && onPageChange(totalPages)} + > + {totalPages} + + + ); + } + + return items; + }; + + if (pagination.pages <= 1) { + return null; + } + + return ( +
+ + + + { + e.preventDefault(); + if (currentPage > 1) onPageChange(currentPage - 1); + }} + aria-disabled={currentPage === 1} + className={currentPage === 1 ? "pointer-events-none opacity-50" : ""} + /> + + + {getPaginationItems()} + + + { + e.preventDefault(); + if (currentPage < pagination.pages) onPageChange(currentPage + 1); + }} + aria-disabled={currentPage === pagination.pages} + className={ + currentPage === pagination.pages + ? "pointer-events-none opacity-50" + : "" + } + /> + + + +
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx b/inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx new file mode 100644 index 0000000..89a5065 --- /dev/null +++ b/inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx @@ -0,0 +1,423 @@ +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../ui/table"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Skeleton } from "../ui/skeleton"; +import { FileText } from "lucide-react"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../ui/tooltip"; +import { + getPurchaseOrderStatusLabel, + getReceivingStatusLabel, + getPurchaseOrderStatusVariant, + getReceivingStatusVariant, +} from "../../types/status-codes"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../ui/card"; + +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 PurchaseOrdersTableProps { + purchaseOrders: PurchaseOrder[]; + loading: boolean; + summary: { order_count: number } | null; + sortColumn: string; + sortDirection: "asc" | "desc"; + handleSort: (column: string) => void; +} + +export default function PurchaseOrdersTable({ + purchaseOrders, + loading, + summary, + sortColumn, + sortDirection, + handleSort +}: PurchaseOrdersTableProps) { + // Helper functions + 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"} + + ); + } + }; + + const getStatusBadge = (status: number, recordType: string) => { + if (recordType === "receiving_only") { + return ( + + {getReceivingStatusLabel(status)} + + ); + } + + return ( + + {getPurchaseOrderStatusLabel(status)} + + ); + }; + + const formatNumber = (value: number) => { + return value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }; + + const formatCurrency = (value: number) => { + return `$${formatNumber(value)}`; + }; + + const formatPercent = (value: number) => { + return ( + (value * 100).toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + "%" + ); + }; + + // Update sort indicators in table headers + const getSortIndicator = (column: string) => { + if (sortColumn !== column) return null; + return sortDirection === "asc" ? " ↑" : " ↓"; + }; + + return ( + + + Purchase Orders & Receivings +
+ {loading ? ( + + ) : ( + `${summary?.order_count.toLocaleString()} orders` + )} +
+
+ + + + + Type + + + + + + + + + + Note + + + + + + + + + + + + + + + + + + + + + + + + + {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 ( + + + {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)} + +
+ ); + }) + ) : ( + + + No purchase orders found + + + )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/components/purchase-orders/VendorMetricsCard.tsx b/inventory/src/components/purchase-orders/VendorMetricsCard.tsx new file mode 100644 index 0000000..3b7f748 --- /dev/null +++ b/inventory/src/components/purchase-orders/VendorMetricsCard.tsx @@ -0,0 +1,354 @@ +import { useState, useEffect } from "react"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../components/ui/card"; +import { Skeleton } from "../../components/ui/skeleton"; +import { BarChart3, Loader2 } from "lucide-react"; +import { Button } from "../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../components/ui/dialog"; +import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "../../components/ui/table"; + +// Add this constant for pie chart colors +const COLORS = [ + "#0088FE", + "#00C49F", + "#FFBB28", + "#FF8042", + "#8884D8", + "#82CA9D", + "#FFC658", + "#FF7C43", +]; + +// The renderActiveShape function for pie charts +const renderActiveShape = (props: any) => { + 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 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} + + ))} + + {`$${Number(total_spend).toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}`} + + + ); +}; + +interface VendorMetricsCardProps { + loading: boolean; + yearlyVendorData: { + vendor: string; + orders: number; + total_spend: number; + percentage?: number; + }[]; + yearlyDataLoading: boolean; +} + +export default function VendorMetricsCard({ + loading, + yearlyVendorData, + yearlyDataLoading, +}: VendorMetricsCardProps) { + const [vendorAnalysisOpen, setVendorAnalysisOpen] = useState(false); + const [activeVendorIndex, setActiveVendorIndex] = useState< + number | undefined + >(); + const [initialLoading, setInitialLoading] = useState(true); + + // Only show loading state on initial load, not during table refreshes + useEffect(() => { + if (yearlyVendorData.length > 0 && !yearlyDataLoading) { + setInitialLoading(false); + } + }, [yearlyVendorData, yearlyDataLoading]); + + const formatNumber = (value: number) => { + return value.toLocaleString("en-US", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + }; + + const formatCurrency = (value: number) => { + return `$${formatNumber(value)}`; + }; + + const formatPercent = (value: number) => { + return ( + (value * 100).toLocaleString("en-US", { + minimumFractionDigits: 1, + maximumFractionDigits: 1, + }) + "%" + ); + }; + + // Prepare vendor chart data + const prepareVendorChartData = () => { + 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); + }; + + // Get all vendors for table + const getAllVendorsForTable = () => { + if (!yearlyVendorData.length) return []; + return [...yearlyVendorData].sort((a, b) => b.total_spend - a.total_spend); + }; + + // Vendor analysis table component + const VendorAnalysisTable = () => { + const vendorData = getAllVendorsForTable(); + + if (!vendorData.length) { + return yearlyDataLoading ? ( +
+ +
+ ) : ( +
+ No supplier data available for the past 12 months +
+ ); + } + + return ( +
+ {yearlyDataLoading ? ( +
+ +
+ ) : ( + <> +
+ + Showing received inventory by supplier for the past 12 months + + {vendorData.length} suppliers found +
+ + + + Supplier + 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 + )} + + + ); + })} + +
+ + )} +
+ ); + }; + + return ( + + + + Received by Supplier + + + + + + + + + + Received Inventory by Supplier + + +
+ +
+
+
+
+ + {initialLoading || loading ? ( +
+ +
+ ) : ( + <> +
+ + + + renderActiveShape({ ...props, category: props.vendor }) + } + onMouseEnter={(_, index) => setActiveVendorIndex(index)} + onMouseLeave={() => setActiveVendorIndex(undefined)} + > + {prepareVendorChartData().map((entry, index) => ( + + ))} + + + +
+ + )} +
+
+ ); +} \ No newline at end of file diff --git a/inventory/src/pages/PurchaseOrders.tsx b/inventory/src/pages/PurchaseOrders.tsx index 0e41111..4c157f0 100644 --- a/inventory/src/pages/PurchaseOrders.tsx +++ b/inventory/src/pages/PurchaseOrders.tsx @@ -1,60 +1,10 @@ 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"; -import { - Pagination, - PaginationContent, - PaginationEllipsis, - PaginationItem, - PaginationLink, - PaginationNext, - PaginationPrevious, -} from "../components/ui/pagination"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "../components/ui/tooltip"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "../components/ui/dialog"; -import { - PurchaseOrderStatus, - getPurchaseOrderStatusLabel, - getReceivingStatusLabel, - getPurchaseOrderStatusVariant, - getReceivingStatusVariant, -} from "../types/status-codes"; -import { PieChart, Pie, ResponsiveContainer, Cell, Sector } from "recharts"; +import OrderMetricsCard from "../components/purchase-orders/OrderMetricsCard"; +import VendorMetricsCard from "../components/purchase-orders/VendorMetricsCard"; +import CategoryMetricsCard from "../components/purchase-orders/CategoryMetricsCard"; +import PaginationControls from "../components/purchase-orders/PaginationControls"; +import PurchaseOrdersTable from "../components/purchase-orders/PurchaseOrdersTable"; +import FilterControls from "../components/purchase-orders/FilterControls"; interface PurchaseOrder { id: number | string; @@ -107,118 +57,6 @@ interface ReceivingStatus { max_delivery_days?: number; } -interface PurchaseOrdersResponse { - orders: PurchaseOrder[]; - summary: { - order_count: number; - total_ordered: number; - total_received: number; - fulfillment_rate: number; - total_value: number; - avg_cost: number; - }; - pagination: { - total: number; - pages: number; - page: number; - limit: number; - }; - filters: { - vendors: string[]; - statuses: number[]; - }; -} - -// Add this constant for pie chart colors -const COLORS = [ - "#0088FE", - "#00C49F", - "#FFBB28", - "#FF8042", - "#8884D8", - "#82CA9D", - "#FFC658", - "#FF7C43", -]; - -// Replace the renderActiveShape function with one matching StockMetrics.tsx -const renderActiveShape = (props: any) => { - 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 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} - - ))} - - {`$${Number(total_spend).toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - })}`} - - - ); -}; export default function PurchaseOrders() { const [purchaseOrders, setPurchaseOrders] = useState([]); @@ -249,14 +87,14 @@ export default function PurchaseOrders() { page: 1, limit: 100, }); - const [costAnalysisOpen, setCostAnalysisOpen] = useState(false); - const [activeSpendingIndex, setActiveSpendingIndex] = useState< + const [] = useState(false); + const [] = useState< number | undefined >(); - const [activeVendorIndex, setActiveVendorIndex] = useState< + const [] = useState< number | undefined >(); - const [vendorAnalysisOpen, setVendorAnalysisOpen] = useState(false); + const [] = useState(false); const [yearlyVendorData, setYearlyVendorData] = useState< { vendor: string; @@ -279,43 +117,6 @@ export default function PurchaseOrders() { 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), - }, - ]; - - 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" }, - ]; - // Use useMemo to compute filters only when filterValues change const filters = useMemo(() => filterValues, [filterValues]); @@ -548,127 +349,6 @@ export default function PurchaseOrders() { }); }; - const getStatusBadge = (status: number, recordType: string) => { - if (recordType === "receiving_only") { - return ( - - {getReceivingStatusLabel(status)} - - ); - } - - return ( - - {getPurchaseOrderStatusLabel(status)} - - ); - }; - - const formatNumber = (value: number) => { - return value.toLocaleString("en-US", { - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }); - }; - - const formatCurrency = (value: number) => { - return `$${formatNumber(value)}`; - }; - - const formatPercent = (value: number) => { - return ( - (value * 100).toLocaleString("en-US", { - minimumFractionDigits: 1, - maximumFractionDigits: 1, - }) + "%" - ); - }; - - // Generate pagination items - const getPaginationItems = () => { - const items = []; - const totalPages = pagination.pages; - const currentPage = page; // Use the local state to ensure sync - - // Always show first page - if (totalPages > 0) { - items.push( - - currentPage !== 1 && setPage(1)} - > - 1 - - - ); - } - - // Add ellipsis if needed - if (currentPage > 3) { - items.push( - - - - ); - } - - // Add pages around current page - const startPage = Math.max(2, currentPage - 1); - const endPage = Math.min(totalPages - 1, currentPage + 1); - - for (let i = startPage; i <= endPage; i++) { - if (i <= 1 || i >= totalPages) continue; // Skip first and last page as they're handled separately - items.push( - - currentPage !== i && setPage(i)} - > - {i} - - - ); - } - - // Add ellipsis if needed - if (currentPage < totalPages - 2) { - items.push( - - - - ); - } - - // Always show last page if there are multiple pages - if (totalPages > 1) { - items.push( - - currentPage !== totalPages && setPage(totalPages)} - > - {totalPages} - - - ); - } - - return items; - }; - - // Update sort indicators in table headers - const getSortIndicator = (column: string) => { - if (sortColumn !== column) return null; - return sortDirection === "asc" ? " ↑" : " ↓"; - }; - // Update this function to fetch yearly data const fetchYearlyData = async () => { if ( @@ -749,893 +429,53 @@ export default function PurchaseOrders() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Update the CostAnalysisTable to always show yearly data - const CostAnalysisTable = () => { - if (!yearlyCategoryData.length) { - return yearlyDataLoading ? ( -
- -
- ) : ( -
- No category data available for the past 12 months -
- ); - } - - return ( -
- {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 - - - - {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)} - - - ); - })} - -
- - )} -
- ); - }; - - // 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 supplier data available for the past 12 months -
- ); - } - - return ( -
- {yearlyDataLoading ? ( -
- -
- ) : ( - <> -
- - Showing received inventory by supplier for the past 12 months - - {vendorData.length} suppliers found -
- - - - Supplier - 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)} -

-
-
-

- Overall Fulfillment Rate -

-

- {formatPercent(summary?.fulfillment_rate || 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"} -

-
- -
- )} -
-
- - {/* Vendor Spending Chart Card */} - - - - Received by Supplier - - - - - - - - - - Received Inventory by Supplier - - -
- -
-
-
-
- - {loading ? ( - - ) : ( - <> -
- - - - renderActiveShape({ ...props, category: props.vendor }) - } - onMouseEnter={(_, index) => setActiveVendorIndex(index)} - onMouseLeave={() => setActiveVendorIndex(undefined)} - > - {prepareVendorChartData().map((entry, index) => ( - - ))} - - - -
- - )} -
-
- - {/* Category Spending Chart Card */} - - - - Received by Category - - - - - - - - - - Received Inventory by Category - - -
- -
-
-
-
- - {loading ? ( - - ) : ( - <> -
- - - setActiveSpendingIndex(index)} - onMouseLeave={() => setActiveSpendingIndex(undefined)} - > - {prepareSpendingChartData().map((entry, index) => ( - - ))} - - - -
- - )} -
-
-
- ); - return (

Purchase Orders

- {/* Metrics Overview */} - {renderMetricsCards()} - - {/* Filters */} -
- setSearchInput(e.target.value)} - className="max-w-xs" - disabled={loading} +
+ + + - - - - {(filterValues.search || filterValues.status !== "all" || filterValues.vendor !== "all" || filterValues.recordType !== "all") && ( - - )}
- {/* Purchase Orders Table */} - - - Purchase Orders & Receivings -
- {loading ? ( - - ) : ( - `${summary?.order_count.toLocaleString()} orders` - )} -
-
- - - - - Type - - - - - - - - - - Note - - - - - - - - - - - - - - - - - - - - - - - - - {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 ( - - - {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)} - -
- ); - }) - ) : ( - - - No purchase orders found - - - )} -
-
-
-
- - {/* Pagination */} - {pagination.pages > 1 && ( -
- - - - { - e.preventDefault(); - if (page > 1) setPage(page - 1); - }} - aria-disabled={page === 1} - className={page === 1 ? "pointer-events-none opacity-50" : ""} - /> - - - {getPaginationItems()} - - - { - e.preventDefault(); - if (page < pagination.pages) setPage(page + 1); - }} - aria-disabled={page === pagination.pages} - className={ - page === pagination.pages - ? "pointer-events-none opacity-50" - : "" - } - /> - - - -
- )} +
); }