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