Refactor purchase orders page into individual components
This commit is contained in:
383
inventory/src/components/purchase-orders/CategoryMetricsCard.tsx
Normal file
383
inventory/src/components/purchase-orders/CategoryMetricsCard.tsx
Normal file
@@ -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 (
|
||||
<g>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius - 1}
|
||||
outerRadius={outerRadius + 4}
|
||||
fill={fill}
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + i * 16}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
className="text-xs"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{`$${Number(total_spend).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 text-muted-foreground">
|
||||
No category data available for the past 12 months
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{yearlyDataLoading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium mb-2 px-4 flex justify-between">
|
||||
<span>
|
||||
Showing received inventory by category for the past 12 months
|
||||
</span>
|
||||
<span>{yearlyCategoryData.length} categories found</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground px-4 mb-2">
|
||||
Note: items can be in multiple categories, so the sum of the
|
||||
categories will not equal the total spend.
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Products</TableHead>
|
||||
<TableHead>Avg. Cost</TableHead>
|
||||
<TableHead>Price Variance</TableHead>
|
||||
<TableHead>Total Spend</TableHead>
|
||||
<TableHead>% of Total</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{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 (
|
||||
<TableRow key={category.category}>
|
||||
<TableCell className="font-medium">
|
||||
{category.category || "Uncategorized"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.unique_products?.toLocaleString() || "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.avg_cost !== undefined
|
||||
? formatCurrency(category.avg_cost)
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{category.cost_variance !== undefined
|
||||
? parseFloat(
|
||||
category.cost_variance.toFixed(2)
|
||||
).toLocaleString()
|
||||
: "N/A"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatCurrency(category.total_spend)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatPercent(totalSpendPercentage)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Received by Category
|
||||
</CardTitle>
|
||||
<Dialog open={costAnalysisOpen} onOpenChange={setCostAnalysisOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" disabled={initialLoading || loading}>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[90%] w-fit">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Received Inventory by Category</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
<CostAnalysisTable />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{initialLoading || loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-[170px]">
|
||||
<Skeleton className="h-[170px] w-[170px] rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[170px] relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart margin={{ top: 30, right: 0, left: 0, bottom: 30 }}>
|
||||
<Pie
|
||||
data={prepareSpendingChartData()}
|
||||
dataKey="total_spend"
|
||||
nameKey="category"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeSpendingIndex}
|
||||
activeShape={renderActiveShape}
|
||||
onMouseEnter={(_, index) => setActiveSpendingIndex(index)}
|
||||
onMouseLeave={() => setActiveSpendingIndex(undefined)}
|
||||
>
|
||||
{prepareSpendingChartData().map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.category}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
155
inventory/src/components/purchase-orders/FilterControls.tsx
Normal file
155
inventory/src/components/purchase-orders/FilterControls.tsx
Normal file
@@ -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 (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-4">
|
||||
<Input
|
||||
placeholder="Search orders..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
className="max-w-xs"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Select
|
||||
value={filterValues.status}
|
||||
onValueChange={handleStatusChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filterValues.vendor}
|
||||
onValueChange={handleVendorChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Select supplier" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Suppliers</SelectItem>
|
||||
{filterOptions?.vendors?.map((vendor) => (
|
||||
<SelectItem key={vendor} value={vendor}>
|
||||
{vendor}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filterValues.recordType}
|
||||
onValueChange={handleRecordTypeChange}
|
||||
disabled={loading}
|
||||
>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Record Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RECORD_TYPE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{(filterValues.search || filterValues.status !== "all" || filterValues.vendor !== "all" || filterValues.recordType !== "all") && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearFilters}
|
||||
disabled={loading}
|
||||
title="Clear filters"
|
||||
className="gap-1"
|
||||
>
|
||||
<span>Clear</span> ✕
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
inventory/src/components/purchase-orders/OrderMetricsCard.tsx
Normal file
122
inventory/src/components/purchase-orders/OrderMetricsCard.tsx
Normal file
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Order Metrics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{initialLoading || loading ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* 5 rows of skeleton metrics */}
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="flex items-baseline justify-between">
|
||||
<Skeleton className="h-4 w-32" />
|
||||
<Skeleton className="h-6 w-16" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Avg. Cost per PO
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{formatCurrency(summary?.avg_cost || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Overall Fulfillment Rate
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{formatPercent(summary?.fulfillment_rate || 0)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Total Orders
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{summary?.order_count.toLocaleString() || 0}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Avg. Delivery Days
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{summary?.avg_delivery_days ? summary.avg_delivery_days.toFixed(1) : "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between">
|
||||
<p className="text-sm font-medium text-muted-foreground">
|
||||
Longest Delivery Days
|
||||
</p>
|
||||
<p className="text-lg font-bold">
|
||||
{summary?.max_delivery_days ? summary.max_delivery_days.toFixed(0) : "N/A"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
140
inventory/src/components/purchase-orders/PaginationControls.tsx
Normal file
140
inventory/src/components/purchase-orders/PaginationControls.tsx
Normal file
@@ -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(
|
||||
<PaginationItem key="first">
|
||||
<PaginationLink
|
||||
isActive={currentPage === 1}
|
||||
onClick={() => currentPage !== 1 && onPageChange(1)}
|
||||
>
|
||||
1
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (currentPage > 3) {
|
||||
items.push(
|
||||
<PaginationItem key="ellipsis-1">
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// 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(
|
||||
<PaginationItem key={i}>
|
||||
<PaginationLink
|
||||
isActive={currentPage === i}
|
||||
onClick={() => currentPage !== i && onPageChange(i)}
|
||||
>
|
||||
{i}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (currentPage < totalPages - 2) {
|
||||
items.push(
|
||||
<PaginationItem key="ellipsis-2">
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Always show last page if there are multiple pages
|
||||
if (totalPages > 1) {
|
||||
items.push(
|
||||
<PaginationItem key="last">
|
||||
<PaginationLink
|
||||
isActive={currentPage === totalPages}
|
||||
onClick={() => currentPage !== totalPages && onPageChange(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
if (pagination.pages <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center mb-6">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage > 1) onPageChange(currentPage - 1);
|
||||
}}
|
||||
aria-disabled={currentPage === 1}
|
||||
className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPaginationItems()}
|
||||
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (currentPage < pagination.pages) onPageChange(currentPage + 1);
|
||||
}}
|
||||
aria-disabled={currentPage === pagination.pages}
|
||||
className={
|
||||
currentPage === pagination.pages
|
||||
? "pointer-events-none opacity-50"
|
||||
: ""
|
||||
}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
423
inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx
Normal file
423
inventory/src/components/purchase-orders/PurchaseOrdersTable.tsx
Normal file
@@ -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 (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center justify-center border-green-500 text-green-700 bg-green-50 px-0 tracking-tight w-[85px]"
|
||||
>
|
||||
Received PO
|
||||
</Badge>
|
||||
);
|
||||
case "po_only":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center justify-center border-blue-500 text-blue-700 bg-blue-50 px-0 tracking-tight w-[85px]"
|
||||
>
|
||||
PO
|
||||
</Badge>
|
||||
);
|
||||
case "receiving_only":
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center justify-center border-amber-500 text-amber-700 bg-amber-50 px-0 tracking-tight w-[85px]"
|
||||
>
|
||||
Receiving
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="flex items-center justify-center border-gray-500 text-gray-700 bg-gray-50 px-0 tracking-tight w-[85px]"
|
||||
>
|
||||
{recordType || "Unknown"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: number, recordType: string) => {
|
||||
if (recordType === "receiving_only") {
|
||||
return (
|
||||
<Badge
|
||||
className="w-[100px] flex items-center justify-center px-0 tracking-tight"
|
||||
variant={getReceivingStatusVariant(status)}
|
||||
>
|
||||
{getReceivingStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge
|
||||
className="w-[100px] flex items-center justify-center px-0 tracking-tight"
|
||||
variant={getPurchaseOrderStatusVariant(status)}
|
||||
>
|
||||
{getPurchaseOrderStatusLabel(status)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<Card className="mb-6">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>Purchase Orders & Receivings</CardTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{loading ? (
|
||||
<Skeleton className="h-4 w-24" />
|
||||
) : (
|
||||
`${summary?.order_count.toLocaleString()} orders`
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table
|
||||
className="table-fixed"
|
||||
style={{ tableLayout: "fixed", width: "100%"}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[100px] text-center">Type</TableHead>
|
||||
<TableHead className="w-[60px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("id")}
|
||||
disabled={loading}
|
||||
>
|
||||
ID{getSortIndicator("id")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[140px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("vendor_name")}
|
||||
disabled={loading}
|
||||
>
|
||||
Supplier{getSortIndicator("vendor_name")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[115px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("status")}
|
||||
disabled={loading}
|
||||
>
|
||||
Status{getSortIndicator("status")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[150px] text-center">Note</TableHead>
|
||||
<TableHead className="w-[90px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("total_cost")}
|
||||
disabled={loading}
|
||||
>
|
||||
Total Cost{getSortIndicator("total_cost")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[70px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("total_items")}
|
||||
disabled={loading}
|
||||
>
|
||||
Products{getSortIndicator("total_items")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("order_date")}
|
||||
disabled={loading}
|
||||
>
|
||||
Order Date{getSortIndicator("order_date")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("receiving_date")}
|
||||
disabled={loading}
|
||||
>
|
||||
Rec'd Date{getSortIndicator("receiving_date")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[70px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("total_quantity")}
|
||||
disabled={loading}
|
||||
>
|
||||
Ordered{getSortIndicator("total_quantity")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("total_received")}
|
||||
disabled={loading}
|
||||
>
|
||||
Received{getSortIndicator("total_received")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="ghost"
|
||||
onClick={() => !loading && handleSort("fulfillment_rate")}
|
||||
disabled={loading}
|
||||
>
|
||||
% Fulfilled{getSortIndicator("fulfillment_rate")}
|
||||
</Button>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
// Skeleton rows for loading state
|
||||
Array(50)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<TableRow key={`skeleton-${index}`}>
|
||||
<TableCell className="w-[100px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[60px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[140px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[115px]">
|
||||
<Skeleton className="h-6 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[150px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[90px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[70px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[90px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[90px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[70px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[80px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
<TableCell className="w-[80px]">
|
||||
<Skeleton className="h-5 w-full" />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : 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 (
|
||||
<TableRow
|
||||
key={`${po.id}-${po.record_type}`}
|
||||
className={rowClassName}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
{getRecordTypeIndicator(po.record_type)}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold text-center">{po.id}</TableCell>
|
||||
<TableCell>{po.vendor_name}</TableCell>
|
||||
<TableCell>
|
||||
{getStatusBadge(po.status, po.record_type)}
|
||||
</TableCell>
|
||||
<TableCell className="truncate text-center">
|
||||
{po.short_note ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="text-left flex items-center gap-1">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span className="truncate">
|
||||
{po.short_note}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{po.short_note}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{formatCurrency(po.total_cost)}</TableCell>
|
||||
<TableCell className="text-center">{po.total_items.toLocaleString()}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{po.order_date
|
||||
? new Date(po.order_date).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}
|
||||
)
|
||||
: ""}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{po.receiving_date
|
||||
? new Date(po.receiving_date).toLocaleDateString(
|
||||
"en-US",
|
||||
{
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
}
|
||||
)
|
||||
: ""}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
{po.total_quantity.toLocaleString()}
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
{po.total_received.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right" >
|
||||
{po.fulfillment_rate === null
|
||||
? "N/A"
|
||||
: formatPercent(po.fulfillment_rate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={12}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
No purchase orders found
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
354
inventory/src/components/purchase-orders/VendorMetricsCard.tsx
Normal file
354
inventory/src/components/purchase-orders/VendorMetricsCard.tsx
Normal file
@@ -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 (
|
||||
<g>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
innerRadius={innerRadius}
|
||||
outerRadius={outerRadius}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
fill={fill}
|
||||
/>
|
||||
<Sector
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
startAngle={startAngle}
|
||||
endAngle={endAngle}
|
||||
innerRadius={outerRadius - 1}
|
||||
outerRadius={outerRadius + 4}
|
||||
fill={fill}
|
||||
/>
|
||||
{lines.map((line, i) => (
|
||||
<text
|
||||
key={i}
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={-20 + i * 16}
|
||||
textAnchor="middle"
|
||||
fill="#888888"
|
||||
className="text-xs"
|
||||
>
|
||||
{line}
|
||||
</text>
|
||||
))}
|
||||
<text
|
||||
x={cx}
|
||||
y={cy}
|
||||
dy={lines.length * 16 - 10}
|
||||
textAnchor="middle"
|
||||
fill="#000000"
|
||||
className="text-base font-medium"
|
||||
>
|
||||
{`$${Number(total_spend).toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})}`}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
};
|
||||
|
||||
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 ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center p-4 text-muted-foreground">
|
||||
No supplier data available for the past 12 months
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{yearlyDataLoading ? (
|
||||
<div className="flex justify-center p-4">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium mb-2 flex justify-between items-center px-4">
|
||||
<span>
|
||||
Showing received inventory by supplier for the past 12 months
|
||||
</span>
|
||||
<span>{vendorData.length} suppliers found</span>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Supplier</TableHead>
|
||||
<TableHead>Orders</TableHead>
|
||||
<TableHead>Total Spend</TableHead>
|
||||
<TableHead>% of Total</TableHead>
|
||||
<TableHead>Avg. Order Value</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vendorData.map((vendor) => {
|
||||
return (
|
||||
<TableRow key={vendor.vendor}>
|
||||
<TableCell className="font-medium">
|
||||
{vendor.vendor}
|
||||
</TableCell>
|
||||
<TableCell>{vendor.orders.toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
{formatCurrency(vendor.total_spend)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatPercent(vendor.percentage || 0)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatCurrency(
|
||||
vendor.orders ? vendor.total_spend / vendor.orders : 0
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">
|
||||
Received by Supplier
|
||||
</CardTitle>
|
||||
<Dialog
|
||||
open={vendorAnalysisOpen}
|
||||
onOpenChange={setVendorAnalysisOpen}
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" disabled={initialLoading || loading}>
|
||||
<BarChart3 className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[90%] w-fit">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5" />
|
||||
<span>Received Inventory by Supplier</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
<VendorAnalysisTable />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{initialLoading || loading ? (
|
||||
<div className="flex flex-col items-center justify-center h-[170px]">
|
||||
<Skeleton className="h-[170px] w-[170px] rounded-full" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[170px] relative">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={prepareVendorChartData()}
|
||||
dataKey="total_spend"
|
||||
nameKey="vendor"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={1}
|
||||
activeIndex={activeVendorIndex}
|
||||
activeShape={(props: any) =>
|
||||
renderActiveShape({ ...props, category: props.vendor })
|
||||
}
|
||||
onMouseEnter={(_, index) => setActiveVendorIndex(index)}
|
||||
onMouseLeave={() => setActiveVendorIndex(undefined)}
|
||||
>
|
||||
{prepareVendorChartData().map((entry, index) => (
|
||||
<Cell
|
||||
key={entry.vendor}
|
||||
fill={COLORS[index % COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user