PO-related fixes
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/card';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../components/ui/table';
|
||||
import { Loader2, ArrowUpDown } from 'lucide-react';
|
||||
import { Loader2, ArrowUpDown, Info, BarChart3 } from 'lucide-react';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
@@ -15,10 +15,26 @@ import {
|
||||
import {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '../components/ui/pagination';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '../components/ui/tooltip';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../components/ui/dialog";
|
||||
import { motion } from 'motion/react';
|
||||
import {
|
||||
PurchaseOrderStatus,
|
||||
@@ -29,7 +45,7 @@ import {
|
||||
} from '../types/status-codes';
|
||||
|
||||
interface PurchaseOrder {
|
||||
id: number;
|
||||
id: number | string;
|
||||
vendor_name: string;
|
||||
order_date: string;
|
||||
status: number;
|
||||
@@ -59,6 +75,9 @@ interface CostAnalysis {
|
||||
total_spend_by_category: {
|
||||
category: string;
|
||||
total_spend: number;
|
||||
unique_products?: number;
|
||||
avg_cost?: number;
|
||||
cost_variance?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
@@ -89,7 +108,7 @@ interface PurchaseOrdersResponse {
|
||||
};
|
||||
filters: {
|
||||
vendors: string[];
|
||||
statuses: string[];
|
||||
statuses: number[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -109,7 +128,7 @@ export default function PurchaseOrders() {
|
||||
});
|
||||
const [filterOptions, setFilterOptions] = useState<{
|
||||
vendors: string[];
|
||||
statuses: string[];
|
||||
statuses: number[];
|
||||
}>({
|
||||
vendors: [],
|
||||
statuses: []
|
||||
@@ -120,6 +139,7 @@ export default function PurchaseOrders() {
|
||||
page: 1,
|
||||
limit: 100,
|
||||
});
|
||||
const [costAnalysisOpen, setCostAnalysisOpen] = useState(false);
|
||||
|
||||
const STATUS_FILTER_OPTIONS = [
|
||||
{ value: 'all', label: 'All Statuses' },
|
||||
@@ -133,14 +153,15 @@ export default function PurchaseOrders() {
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const searchParams = new URLSearchParams({
|
||||
page: page.toString(),
|
||||
limit: '100',
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
...filters.search && { search: filters.search },
|
||||
...filters.status && { status: filters.status },
|
||||
...filters.vendor && { vendor: filters.vendor },
|
||||
...filters.status !== 'all' && { status: filters.status },
|
||||
...filters.vendor !== 'all' && { vendor: filters.vendor },
|
||||
});
|
||||
|
||||
const [
|
||||
@@ -205,7 +226,23 @@ export default function PurchaseOrders() {
|
||||
console.error('Failed to fetch cost analysis:', await costAnalysisRes.text());
|
||||
}
|
||||
|
||||
setPurchaseOrders(purchaseOrdersData.orders);
|
||||
// Process orders data
|
||||
const processedOrders = purchaseOrdersData.orders.map(order => {
|
||||
let processedOrder = {
|
||||
...order,
|
||||
status: Number(order.status),
|
||||
receiving_status: Number(order.receiving_status),
|
||||
total_items: Number(order.total_items) || 0,
|
||||
total_quantity: Number(order.total_quantity) || 0,
|
||||
total_cost: Number(order.total_cost) || 0,
|
||||
total_received: Number(order.total_received) || 0,
|
||||
fulfillment_rate: Number(order.fulfillment_rate) || 0
|
||||
};
|
||||
|
||||
return processedOrder;
|
||||
});
|
||||
|
||||
setPurchaseOrders(processedOrders);
|
||||
setPagination(purchaseOrdersData.pagination);
|
||||
setFilterOptions(purchaseOrdersData.filters);
|
||||
setSummary(purchaseOrdersData.summary);
|
||||
@@ -280,6 +317,10 @@ export default function PurchaseOrders() {
|
||||
});
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number) => {
|
||||
return `$${formatNumber(value)}`;
|
||||
};
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
return (value * 100).toLocaleString('en-US', {
|
||||
minimumFractionDigits: 1,
|
||||
@@ -287,6 +328,141 @@ export default function PurchaseOrders() {
|
||||
}) + '%';
|
||||
};
|
||||
|
||||
// Generate pagination items
|
||||
const getPaginationItems = () => {
|
||||
const items = [];
|
||||
const maxPagesToShow = 5;
|
||||
const totalPages = pagination.pages;
|
||||
|
||||
// Always show first page
|
||||
if (totalPages > 0) {
|
||||
items.push(
|
||||
<PaginationItem key="first">
|
||||
<PaginationLink
|
||||
isActive={page === 1}
|
||||
onClick={() => page !== 1 && setPage(1)}
|
||||
>
|
||||
1
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (page > 3) {
|
||||
items.push(
|
||||
<PaginationItem key="ellipsis-1">
|
||||
<PaginationEllipsis />
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add pages around current page
|
||||
const startPage = Math.max(2, page - 1);
|
||||
const endPage = Math.min(totalPages - 1, page + 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={page === i}
|
||||
onClick={() => page !== i && setPage(i)}
|
||||
>
|
||||
{i}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Add ellipsis if needed
|
||||
if (page < 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={page === totalPages}
|
||||
onClick={() => page !== totalPages && setPage(totalPages)}
|
||||
>
|
||||
{totalPages}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
);
|
||||
}
|
||||
|
||||
return items;
|
||||
};
|
||||
|
||||
// Cost Analysis table component
|
||||
const CostAnalysisTable = () => {
|
||||
if (!costAnalysis) return null;
|
||||
|
||||
return (
|
||||
<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>
|
||||
{costAnalysis?.total_spend_by_category?.length ?
|
||||
costAnalysis.total_spend_by_category.map((category) => {
|
||||
// Calculate percentage of total spend
|
||||
const totalSpendPercentage =
|
||||
costAnalysis.total_spend_by_category.reduce((sum, cat) => sum + cat.total_spend, 0) > 0
|
||||
? (category.total_spend /
|
||||
costAnalysis.total_spend_by_category.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>
|
||||
);
|
||||
}) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center text-muted-foreground">
|
||||
No cost analysis data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
@@ -315,7 +491,7 @@ export default function PurchaseOrders() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${formatNumber(summary?.total_value || 0)}
|
||||
{formatCurrency(summary?.total_value || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -331,12 +507,44 @@ export default function PurchaseOrders() {
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Avg Cost per PO</CardTitle>
|
||||
<CardTitle className="text-sm font-medium">Spending Analysis</CardTitle>
|
||||
<Dialog open={costAnalysisOpen} onOpenChange={setCostAnalysisOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<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>Purchase Order Spending Analysis by Category</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
This analysis shows spending distribution across product categories
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="overflow-auto max-h-[70vh]">
|
||||
<CostAnalysisTable />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${formatNumber(summary?.avg_cost || 0)}
|
||||
{formatCurrency(summary?.avg_cost || 0)}
|
||||
<div className="text-sm font-normal text-muted-foreground">
|
||||
Avg. Cost per PO
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full mt-2 text-sm"
|
||||
onClick={() => setCostAnalysisOpen(true)}
|
||||
>
|
||||
<BarChart3 className="h-4 w-4 mr-2" />
|
||||
View Category Analysis
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -435,9 +643,11 @@ export default function PurchaseOrders() {
|
||||
<TableCell>{getStatusBadge(po.status, po.receiving_status)}</TableCell>
|
||||
<TableCell>{po.total_items.toLocaleString()}</TableCell>
|
||||
<TableCell>{po.total_quantity.toLocaleString()}</TableCell>
|
||||
<TableCell>${formatNumber(po.total_cost)}</TableCell>
|
||||
<TableCell>{formatCurrency(po.total_cost)}</TableCell>
|
||||
<TableCell>{po.total_received.toLocaleString()}</TableCell>
|
||||
<TableCell>{formatPercent(po.fulfillment_rate)}</TableCell>
|
||||
<TableCell>
|
||||
{po.fulfillment_rate === null ? 'N/A' : formatPercent(po.fulfillment_rate)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{!purchaseOrders.length && (
|
||||
@@ -454,62 +664,38 @@ export default function PurchaseOrders() {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<div className="flex justify-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button
|
||||
onClick={() => setPage(page - 1)}
|
||||
disabled={page === 1}
|
||||
className="h-9 px-4"
|
||||
>
|
||||
<PaginationPrevious className="h-4 w-4" />
|
||||
</Button>
|
||||
<PaginationPrevious
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page > 1) setPage(page - 1);
|
||||
}}
|
||||
aria-disabled={page === 1}
|
||||
className={page === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
|
||||
{getPaginationItems()}
|
||||
|
||||
<PaginationItem>
|
||||
<Button
|
||||
onClick={() => setPage(page + 1)}
|
||||
disabled={page === pagination.pages}
|
||||
className="h-9 px-4"
|
||||
>
|
||||
<PaginationNext className="h-4 w-4" />
|
||||
</Button>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (page < pagination.pages) setPage(page + 1);
|
||||
}}
|
||||
aria-disabled={page === pagination.pages}
|
||||
className={page === pagination.pages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cost Analysis */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cost Analysis by Category</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Category</TableHead>
|
||||
<TableHead>Total Spend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{costAnalysis?.total_spend_by_category?.map((category) => (
|
||||
<TableRow key={category.category}>
|
||||
<TableCell>{category.category}</TableCell>
|
||||
<TableCell>${formatNumber(category.total_spend)}</TableCell>
|
||||
</TableRow>
|
||||
)) || (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
||||
No cost analysis data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user