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
+
+
+
+
+ {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
+
+
+
+
+ {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
-
-
-
-
- {loading ? (
-
- ) : (
- <>
-
-
-
-
- renderActiveShape({ ...props, category: props.vendor })
- }
- onMouseEnter={(_, index) => setActiveVendorIndex(index)}
- onMouseLeave={() => setActiveVendorIndex(undefined)}
- >
- {prepareVendorChartData().map((entry, index) => (
- |
- ))}
-
-
-
-
- >
- )}
-
-
-
- {/* Category Spending Chart Card */}
-
-
-
- Received 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"
- : ""
- }
- />
-
-
-
-
- )}
+
);
}