Add purchase orders page with some bugs left
This commit is contained in:
@@ -8,6 +8,7 @@ import { Orders } from './pages/Orders';
|
||||
import { Settings } from './pages/Settings';
|
||||
import { Analytics } from './pages/Analytics';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import PurchaseOrders from './pages/PurchaseOrders';
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -22,6 +23,7 @@ function App() {
|
||||
<Route path="/products" element={<Products />} />
|
||||
<Route path="/import" element={<Import />} />
|
||||
<Route path="/orders" element={<Orders />} />
|
||||
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
||||
<Route path="/analytics" element={<Analytics />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
</Routes>
|
||||
@@ -30,5 +32,5 @@ function App() {
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Home, Package, ShoppingCart, BarChart2, Settings, Plus, Box } from "lucide-react";
|
||||
import { Home, Package, ShoppingCart, BarChart2, Settings, Plus, Box, ClipboardList } from "lucide-react";
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
@@ -30,6 +30,11 @@ const items = [
|
||||
icon: ShoppingCart,
|
||||
url: "/orders",
|
||||
},
|
||||
{
|
||||
title: "Purchase Orders",
|
||||
icon: ClipboardList,
|
||||
url: "/purchase-orders",
|
||||
},
|
||||
{
|
||||
title: "Analytics",
|
||||
icon: BarChart2,
|
||||
|
||||
445
inventory/src/pages/PurchaseOrders.tsx
Normal file
445
inventory/src/pages/PurchaseOrders.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
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,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
} from '../components/ui/pagination';
|
||||
|
||||
interface PurchaseOrder {
|
||||
id: number;
|
||||
vendor_name: string;
|
||||
order_date: string;
|
||||
status: string;
|
||||
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[];
|
||||
pagination: {
|
||||
total: number;
|
||||
pages: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
};
|
||||
filters: {
|
||||
vendors: string[];
|
||||
statuses: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export default function PurchaseOrders() {
|
||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
||||
const [vendorMetrics, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
||||
const [costAnalysis, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
||||
const [receivingStatus, setReceivingStatus] = 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 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,
|
||||
receivingStatusRes
|
||||
] = await Promise.all([
|
||||
fetch(`/api/purchase-orders?${searchParams}`),
|
||||
fetch('/api/purchase-orders/vendor-metrics'),
|
||||
fetch('/api/purchase-orders/cost-analysis'),
|
||||
fetch('/api/purchase-orders/receiving-status')
|
||||
]);
|
||||
|
||||
const [
|
||||
purchaseOrdersData,
|
||||
vendorMetricsData,
|
||||
costAnalysisData,
|
||||
receivingStatusData
|
||||
] = await Promise.all([
|
||||
purchaseOrdersRes.json(),
|
||||
vendorMetricsRes.json(),
|
||||
costAnalysisRes.json(),
|
||||
receivingStatusRes.json()
|
||||
]);
|
||||
|
||||
setPurchaseOrders(purchaseOrdersData.orders);
|
||||
setPagination(purchaseOrdersData.pagination);
|
||||
setFilterOptions(purchaseOrdersData.filters);
|
||||
setVendorMetrics(vendorMetricsData);
|
||||
setCostAnalysis(costAnalysisData);
|
||||
setReceivingStatus(receivingStatusData);
|
||||
} catch (error) {
|
||||
console.error('Error fetching data:', error);
|
||||
} 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: string) => {
|
||||
const variants: Record<string, { variant: "default" | "secondary" | "destructive" | "outline"; label: string }> = {
|
||||
pending: { variant: "outline", label: "Pending" },
|
||||
received: { variant: "default", label: "Received" },
|
||||
partial: { variant: "secondary", label: "Partial" },
|
||||
cancelled: { variant: "destructive", label: "Cancelled" },
|
||||
};
|
||||
|
||||
const statusConfig = variants[status.toLowerCase()] || variants.pending;
|
||||
return <Badge variant={statusConfig.variant}>{statusConfig.label}</Badge>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div 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">{receivingStatus?.order_count || 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">
|
||||
${(receivingStatus?.total_value || 0).toFixed(2)}
|
||||
</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">
|
||||
{((receivingStatus?.fulfillment_rate || 0) * 100).toFixed(1)}%
|
||||
</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">
|
||||
${(receivingStatus?.avg_cost || 0).toFixed(2)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-center">
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Input
|
||||
placeholder="Search orders..."
|
||||
value={filters.search}
|
||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||
className="h-8 w-[300px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{filterOptions.statuses.map(status => (
|
||||
<SelectItem key={status} value={status}>{status}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select
|
||||
value={filters.vendor}
|
||||
onValueChange={(value) => setFilters(prev => ({ ...prev, vendor: value }))}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[180px]">
|
||||
<SelectValue placeholder="Vendor" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Vendors</SelectItem>
|
||||
{filterOptions.vendors.map(vendor => (
|
||||
<SelectItem key={vendor} value={vendor}>{vendor}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</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)}</TableCell>
|
||||
<TableCell>{po.total_items}</TableCell>
|
||||
<TableCell>{po.total_quantity}</TableCell>
|
||||
<TableCell>${po.total_cost.toFixed(2)}</TableCell>
|
||||
<TableCell>{po.total_received}</TableCell>
|
||||
<TableCell>{(po.fulfillment_rate * 100).toFixed(1)}%</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>
|
||||
<PaginationPrevious
|
||||
onClick={() => setPage(p => Math.max(1, p - 1))}
|
||||
disabled={page === 1}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{Array.from({ length: pagination.pages }, (_, i) => i + 1).map((p) => (
|
||||
<PaginationItem key={p}>
|
||||
<PaginationLink
|
||||
onClick={() => setPage(p)}
|
||||
isActive={p === page}
|
||||
>
|
||||
{p}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
onClick={() => setPage(p => Math.min(pagination.pages, p + 1))}
|
||||
disabled={page === pagination.pages}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Vendor Performance */}
|
||||
<Card className="mb-6">
|
||||
<CardHeader>
|
||||
<CardTitle>Vendor Performance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Vendor</TableHead>
|
||||
<TableHead>Total Orders</TableHead>
|
||||
<TableHead>Avg Delivery Days</TableHead>
|
||||
<TableHead>Fulfillment Rate</TableHead>
|
||||
<TableHead>Avg Unit Cost</TableHead>
|
||||
<TableHead>Total Spend</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vendorMetrics.map((vendor) => (
|
||||
<TableRow key={vendor.vendor_name}>
|
||||
<TableCell>{vendor.vendor_name}</TableCell>
|
||||
<TableCell>{vendor.total_orders}</TableCell>
|
||||
<TableCell>{vendor.avg_delivery_days.toFixed(1)}</TableCell>
|
||||
<TableCell>{(vendor.fulfillment_rate * 100).toFixed(1)}%</TableCell>
|
||||
<TableCell>${vendor.avg_unit_cost.toFixed(2)}</TableCell>
|
||||
<TableCell>${vendor.total_spend.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 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>${category.total_spend.toFixed(2)}</TableCell>
|
||||
</TableRow>
|
||||
)) || (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="text-center text-muted-foreground">
|
||||
No cost analysis data available
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user