515 lines
16 KiB
TypeScript
515 lines
16 KiB
TypeScript
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 { Button } from '../components/ui/button';
|
|
import { Input } from '../components/ui/input';
|
|
import { Badge } from '../components/ui/badge';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '../components/ui/select';
|
|
import {
|
|
Pagination,
|
|
PaginationContent,
|
|
PaginationItem,
|
|
PaginationNext,
|
|
PaginationPrevious,
|
|
} from '../components/ui/pagination';
|
|
import { motion } from 'motion/react';
|
|
import {
|
|
PurchaseOrderStatus,
|
|
getPurchaseOrderStatusLabel,
|
|
getReceivingStatusLabel,
|
|
getPurchaseOrderStatusVariant,
|
|
getReceivingStatusVariant
|
|
} from '../types/status-codes';
|
|
|
|
interface PurchaseOrder {
|
|
id: number;
|
|
vendor_name: string;
|
|
order_date: string;
|
|
status: number;
|
|
receiving_status: number;
|
|
total_items: number;
|
|
total_quantity: number;
|
|
total_cost: number;
|
|
total_received: number;
|
|
fulfillment_rate: number;
|
|
}
|
|
|
|
interface VendorMetrics {
|
|
vendor_name: string;
|
|
total_orders: number;
|
|
avg_delivery_days: number;
|
|
fulfillment_rate: number;
|
|
avg_unit_cost: number;
|
|
total_spend: number;
|
|
}
|
|
|
|
interface CostAnalysis {
|
|
unique_products: number;
|
|
avg_cost: number;
|
|
min_cost: number;
|
|
max_cost: number;
|
|
cost_variance: number;
|
|
total_spend_by_category: {
|
|
category: string;
|
|
total_spend: number;
|
|
}[];
|
|
}
|
|
|
|
interface ReceivingStatus {
|
|
order_count: number;
|
|
total_ordered: number;
|
|
total_received: number;
|
|
fulfillment_rate: number;
|
|
total_value: number;
|
|
avg_cost: number;
|
|
}
|
|
|
|
interface PurchaseOrdersResponse {
|
|
orders: PurchaseOrder[];
|
|
summary: {
|
|
order_count: number;
|
|
total_ordered: number;
|
|
total_received: number;
|
|
fulfillment_rate: number;
|
|
total_value: number;
|
|
avg_cost: number;
|
|
};
|
|
pagination: {
|
|
total: number;
|
|
pages: number;
|
|
page: number;
|
|
limit: number;
|
|
};
|
|
filters: {
|
|
vendors: string[];
|
|
statuses: string[];
|
|
};
|
|
}
|
|
|
|
export default function PurchaseOrders() {
|
|
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
|
const [, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
|
const [costAnalysis, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
|
const [summary, setSummary] = useState<ReceivingStatus | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [page, setPage] = useState(1);
|
|
const [sortColumn, setSortColumn] = useState<string>('order_date');
|
|
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
|
const [filters, setFilters] = useState({
|
|
search: '',
|
|
status: 'all',
|
|
vendor: 'all',
|
|
});
|
|
const [filterOptions, setFilterOptions] = useState<{
|
|
vendors: string[];
|
|
statuses: string[];
|
|
}>({
|
|
vendors: [],
|
|
statuses: []
|
|
});
|
|
const [pagination, setPagination] = useState({
|
|
total: 0,
|
|
pages: 0,
|
|
page: 1,
|
|
limit: 100,
|
|
});
|
|
|
|
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 fetchData = async () => {
|
|
try {
|
|
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 },
|
|
});
|
|
|
|
const [
|
|
purchaseOrdersRes,
|
|
vendorMetricsRes,
|
|
costAnalysisRes
|
|
] = await Promise.all([
|
|
fetch(`/api/purchase-orders?${searchParams}`),
|
|
fetch('/api/purchase-orders/vendor-metrics'),
|
|
fetch('/api/purchase-orders/cost-analysis')
|
|
]);
|
|
|
|
// Initialize default data
|
|
let purchaseOrdersData: PurchaseOrdersResponse = {
|
|
orders: [],
|
|
summary: {
|
|
order_count: 0,
|
|
total_ordered: 0,
|
|
total_received: 0,
|
|
fulfillment_rate: 0,
|
|
total_value: 0,
|
|
avg_cost: 0
|
|
},
|
|
pagination: {
|
|
total: 0,
|
|
pages: 0,
|
|
page: 1,
|
|
limit: 100
|
|
},
|
|
filters: {
|
|
vendors: [],
|
|
statuses: []
|
|
}
|
|
};
|
|
|
|
let vendorMetricsData: VendorMetrics[] = [];
|
|
let costAnalysisData: CostAnalysis = {
|
|
unique_products: 0,
|
|
avg_cost: 0,
|
|
min_cost: 0,
|
|
max_cost: 0,
|
|
cost_variance: 0,
|
|
total_spend_by_category: []
|
|
};
|
|
|
|
// Only try to parse responses if they were successful
|
|
if (purchaseOrdersRes.ok) {
|
|
purchaseOrdersData = await purchaseOrdersRes.json();
|
|
} else {
|
|
console.error('Failed to fetch purchase orders:', await purchaseOrdersRes.text());
|
|
}
|
|
|
|
if (vendorMetricsRes.ok) {
|
|
vendorMetricsData = await vendorMetricsRes.json();
|
|
} else {
|
|
console.error('Failed to fetch vendor metrics:', await vendorMetricsRes.text());
|
|
}
|
|
|
|
if (costAnalysisRes.ok) {
|
|
costAnalysisData = await costAnalysisRes.json();
|
|
} else {
|
|
console.error('Failed to fetch cost analysis:', await costAnalysisRes.text());
|
|
}
|
|
|
|
setPurchaseOrders(purchaseOrdersData.orders);
|
|
setPagination(purchaseOrdersData.pagination);
|
|
setFilterOptions(purchaseOrdersData.filters);
|
|
setSummary(purchaseOrdersData.summary);
|
|
setVendorMetrics(vendorMetricsData);
|
|
setCostAnalysis(costAnalysisData);
|
|
} catch (error) {
|
|
console.error('Error fetching data:', error);
|
|
// Set default values in case of error
|
|
setPurchaseOrders([]);
|
|
setPagination({ total: 0, pages: 0, page: 1, limit: 100 });
|
|
setFilterOptions({ vendors: [], statuses: [] });
|
|
setSummary({
|
|
order_count: 0,
|
|
total_ordered: 0,
|
|
total_received: 0,
|
|
fulfillment_rate: 0,
|
|
total_value: 0,
|
|
avg_cost: 0
|
|
});
|
|
setVendorMetrics([]);
|
|
setCostAnalysis({
|
|
unique_products: 0,
|
|
avg_cost: 0,
|
|
min_cost: 0,
|
|
max_cost: 0,
|
|
cost_variance: 0,
|
|
total_spend_by_category: []
|
|
});
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [page, sortColumn, sortDirection, filters]);
|
|
|
|
const handleSort = (column: string) => {
|
|
if (sortColumn === column) {
|
|
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
|
|
} else {
|
|
setSortColumn(column);
|
|
setSortDirection('asc');
|
|
}
|
|
};
|
|
|
|
const getStatusBadge = (status: number, receivingStatus: number) => {
|
|
// If the PO is canceled, show that status
|
|
if (status === PurchaseOrderStatus.Canceled) {
|
|
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
|
{getPurchaseOrderStatusLabel(status)}
|
|
</Badge>;
|
|
}
|
|
|
|
// If receiving has started, show receiving status
|
|
if (status >= PurchaseOrderStatus.ReceivingStarted) {
|
|
return <Badge variant={getReceivingStatusVariant(receivingStatus)}>
|
|
{getReceivingStatusLabel(receivingStatus)}
|
|
</Badge>;
|
|
}
|
|
|
|
// Otherwise show PO status
|
|
return <Badge variant={getPurchaseOrderStatusVariant(status)}>
|
|
{getPurchaseOrderStatusLabel(status)}
|
|
</Badge>;
|
|
};
|
|
|
|
const formatNumber = (value: number) => {
|
|
return value.toLocaleString('en-US', {
|
|
minimumFractionDigits: 2,
|
|
maximumFractionDigits: 2
|
|
});
|
|
};
|
|
|
|
const formatPercent = (value: number) => {
|
|
return (value * 100).toLocaleString('en-US', {
|
|
minimumFractionDigits: 1,
|
|
maximumFractionDigits: 1
|
|
}) + '%';
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<Loader2 className="h-8 w-8 animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<motion.div layout className="container mx-auto py-6">
|
|
<h1 className="mb-6 text-3xl font-bold">Purchase Orders</h1>
|
|
|
|
{/* Metrics Overview */}
|
|
<div className="mb-6 grid gap-4 md:grid-cols-4">
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Total Orders</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">{summary?.order_count.toLocaleString() || 0}</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Total Value</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
${formatNumber(summary?.total_value || 0)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<CardTitle className="text-sm font-medium">Fulfillment Rate</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
{formatPercent(summary?.fulfillment_rate || 0)}
|
|
</div>
|
|
</CardContent>
|
|
</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>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="text-2xl font-bold">
|
|
${formatNumber(summary?.avg_cost || 0)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="mb-4 flex items-center gap-4">
|
|
<Input
|
|
placeholder="Search orders..."
|
|
value={filters.search}
|
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
|
className="max-w-xs"
|
|
/>
|
|
<Select
|
|
value={filters.status}
|
|
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
|
>
|
|
<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={filters.vendor}
|
|
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
|
|
>
|
|
<SelectTrigger className="w-[180px]">
|
|
<SelectValue placeholder="Select vendor" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Vendors</SelectItem>
|
|
{filterOptions?.vendors?.map(vendor => (
|
|
<SelectItem key={vendor} value={vendor}>
|
|
{vendor}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Purchase Orders Table */}
|
|
<Card className="mb-6">
|
|
<CardHeader>
|
|
<CardTitle>Recent Purchase Orders</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>
|
|
<Button variant="ghost" onClick={() => handleSort('id')}>
|
|
ID <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead>
|
|
<Button variant="ghost" onClick={() => handleSort('vendor_name')}>
|
|
Vendor <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead>
|
|
<Button variant="ghost" onClick={() => handleSort('order_date')}>
|
|
Order Date <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead>
|
|
<Button variant="ghost" onClick={() => handleSort('status')}>
|
|
Status <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead>Total Items</TableHead>
|
|
<TableHead>Total Quantity</TableHead>
|
|
<TableHead>
|
|
<Button variant="ghost" onClick={() => handleSort('total_cost')}>
|
|
Total Cost <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</TableHead>
|
|
<TableHead>Received</TableHead>
|
|
<TableHead>
|
|
<Button variant="ghost" onClick={() => handleSort('fulfillment_rate')}>
|
|
Fulfillment <ArrowUpDown className="ml-2 h-4 w-4" />
|
|
</Button>
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{purchaseOrders.map((po) => (
|
|
<TableRow key={po.id}>
|
|
<TableCell>{po.id}</TableCell>
|
|
<TableCell>{po.vendor_name}</TableCell>
|
|
<TableCell>{new Date(po.order_date).toLocaleDateString()}</TableCell>
|
|
<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>{po.total_received.toLocaleString()}</TableCell>
|
|
<TableCell>{formatPercent(po.fulfillment_rate)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{!purchaseOrders.length && (
|
|
<TableRow>
|
|
<TableCell colSpan={9} className="text-center text-muted-foreground">
|
|
No purchase orders found
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Pagination */}
|
|
{pagination.pages > 1 && (
|
|
<div className="flex justify-center">
|
|
<Pagination>
|
|
<PaginationContent>
|
|
<PaginationItem>
|
|
<Button
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page === 1}
|
|
className="h-9 px-4"
|
|
>
|
|
<PaginationPrevious className="h-4 w-4" />
|
|
</Button>
|
|
</PaginationItem>
|
|
<PaginationItem>
|
|
<Button
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page === pagination.pages}
|
|
className="h-9 px-4"
|
|
>
|
|
<PaginationNext className="h-4 w-4" />
|
|
</Button>
|
|
</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>
|
|
);
|
|
}
|