Add purchase orders page with some bugs left
This commit is contained in:
315
inventory-server/src/routes/purchase-orders.js
Normal file
315
inventory-server/src/routes/purchase-orders.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Get all purchase orders with summary metrics
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
const { search, status, vendor, startDate, endDate, page = 1, limit = 100, sortColumn = 'date', sortDirection = 'desc' } = req.query;
|
||||||
|
|
||||||
|
let whereClause = '1=1';
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
whereClause += ' AND (po.po_id LIKE ? OR po.vendor LIKE ?)';
|
||||||
|
params.push(`%${search}%`, `%${search}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && status !== 'all') {
|
||||||
|
whereClause += ' AND po.status = ?';
|
||||||
|
params.push(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vendor && vendor !== 'all') {
|
||||||
|
whereClause += ' AND po.vendor = ?';
|
||||||
|
params.push(vendor);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startDate) {
|
||||||
|
whereClause += ' AND po.date >= ?';
|
||||||
|
params.push(startDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endDate) {
|
||||||
|
whereClause += ' AND po.date <= ?';
|
||||||
|
params.push(endDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count for pagination
|
||||||
|
const [countResult] = await pool.query(`
|
||||||
|
SELECT COUNT(DISTINCT po_id) as total
|
||||||
|
FROM purchase_orders po
|
||||||
|
WHERE ${whereClause}
|
||||||
|
`, params);
|
||||||
|
|
||||||
|
const total = countResult[0].total;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const pages = Math.ceil(total / limit);
|
||||||
|
|
||||||
|
// Get recent purchase orders
|
||||||
|
const [orders] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
po_id as id,
|
||||||
|
vendor as vendor_name,
|
||||||
|
DATE_FORMAT(date, '%Y-%m-%d') as order_date,
|
||||||
|
status,
|
||||||
|
COUNT(DISTINCT product_id) as total_items,
|
||||||
|
SUM(ordered) as total_quantity,
|
||||||
|
SUM(ordered * cost_price) as total_cost,
|
||||||
|
SUM(received) as total_received,
|
||||||
|
ROUND(
|
||||||
|
SUM(received) / SUM(ordered), 3
|
||||||
|
) as fulfillment_rate
|
||||||
|
FROM purchase_orders po
|
||||||
|
WHERE ${whereClause}
|
||||||
|
GROUP BY po_id, vendor, date, status
|
||||||
|
ORDER BY
|
||||||
|
CASE
|
||||||
|
WHEN ? = 'order_date' THEN date
|
||||||
|
WHEN ? = 'vendor_name' THEN vendor
|
||||||
|
WHEN ? = 'total_cost' THEN SUM(ordered * cost_price)
|
||||||
|
WHEN ? = 'total_received' THEN SUM(received)
|
||||||
|
WHEN ? = 'fulfillment_rate' THEN SUM(received) / SUM(ordered)
|
||||||
|
ELSE date
|
||||||
|
END ${sortDirection === 'desc' ? 'DESC' : 'ASC'}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
`, [...params, sortColumn, sortColumn, sortColumn, sortColumn, sortColumn, Number(limit), offset]);
|
||||||
|
|
||||||
|
// Get unique vendors for filter options
|
||||||
|
const [vendors] = await pool.query(`
|
||||||
|
SELECT DISTINCT vendor
|
||||||
|
FROM purchase_orders
|
||||||
|
WHERE vendor IS NOT NULL AND vendor != ''
|
||||||
|
ORDER BY vendor
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get unique statuses for filter options
|
||||||
|
const [statuses] = await pool.query(`
|
||||||
|
SELECT DISTINCT status
|
||||||
|
FROM purchase_orders
|
||||||
|
WHERE status IS NOT NULL AND status != ''
|
||||||
|
ORDER BY status
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Parse numeric values
|
||||||
|
const parsedOrders = orders.map(order => ({
|
||||||
|
id: order.id,
|
||||||
|
vendor_name: order.vendor_name,
|
||||||
|
order_date: order.order_date,
|
||||||
|
status: order.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
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
orders: parsedOrders,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
pages,
|
||||||
|
page: Number(page),
|
||||||
|
limit: Number(limit)
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
vendors: vendors.map(v => v.vendor),
|
||||||
|
statuses: statuses.map(s => s.status)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching purchase orders:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch purchase orders' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get vendor performance metrics
|
||||||
|
router.get('/vendor-metrics', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const [metrics] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
vendor as vendor_name,
|
||||||
|
COUNT(DISTINCT po_id) as total_orders,
|
||||||
|
SUM(ordered) as total_ordered,
|
||||||
|
SUM(received) as total_received,
|
||||||
|
ROUND(
|
||||||
|
SUM(received) / SUM(ordered), 3
|
||||||
|
) as fulfillment_rate,
|
||||||
|
ROUND(
|
||||||
|
SUM(ordered * cost_price) / SUM(ordered), 2
|
||||||
|
) as avg_unit_cost,
|
||||||
|
SUM(ordered * cost_price) as total_spend,
|
||||||
|
ROUND(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN status = 'received' AND received_date IS NOT NULL AND date IS NOT NULL
|
||||||
|
THEN DATEDIFF(received_date, date)
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
), 1) as avg_delivery_days
|
||||||
|
FROM purchase_orders
|
||||||
|
WHERE vendor IS NOT NULL AND vendor != ''
|
||||||
|
GROUP BY vendor
|
||||||
|
HAVING total_orders > 0
|
||||||
|
ORDER BY total_spend DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Parse numeric values
|
||||||
|
const parsedMetrics = metrics.map(vendor => ({
|
||||||
|
id: vendor.vendor_name,
|
||||||
|
vendor_name: vendor.vendor_name,
|
||||||
|
total_orders: Number(vendor.total_orders) || 0,
|
||||||
|
total_ordered: Number(vendor.total_ordered) || 0,
|
||||||
|
total_received: Number(vendor.total_received) || 0,
|
||||||
|
fulfillment_rate: Number(vendor.fulfillment_rate) || 0,
|
||||||
|
avg_unit_cost: Number(vendor.avg_unit_cost) || 0,
|
||||||
|
total_spend: Number(vendor.total_spend) || 0,
|
||||||
|
avg_delivery_days: Number(vendor.avg_delivery_days) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(parsedMetrics);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching vendor metrics:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch vendor metrics' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get cost analysis
|
||||||
|
router.get('/cost-analysis', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const [analysis] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.categories,
|
||||||
|
COUNT(DISTINCT po.product_id) as unique_products,
|
||||||
|
ROUND(AVG(po.cost_price), 2) as avg_cost,
|
||||||
|
MIN(po.cost_price) as min_cost,
|
||||||
|
MAX(po.cost_price) as max_cost,
|
||||||
|
ROUND(
|
||||||
|
STDDEV(po.cost_price), 2
|
||||||
|
) as cost_variance,
|
||||||
|
SUM(po.ordered * po.cost_price) as total_spend
|
||||||
|
FROM purchase_orders po
|
||||||
|
JOIN products p ON po.product_id = p.product_id
|
||||||
|
GROUP BY p.categories
|
||||||
|
ORDER BY total_spend DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Parse numeric values and add ids for React keys
|
||||||
|
const parsedAnalysis = analysis.map(item => ({
|
||||||
|
id: item.categories || 'Uncategorized',
|
||||||
|
categories: item.categories || 'Uncategorized',
|
||||||
|
unique_products: Number(item.unique_products) || 0,
|
||||||
|
avg_cost: Number(item.avg_cost) || 0,
|
||||||
|
min_cost: Number(item.min_cost) || 0,
|
||||||
|
max_cost: Number(item.max_cost) || 0,
|
||||||
|
cost_variance: Number(item.cost_variance) || 0,
|
||||||
|
total_spend: Number(item.total_spend) || 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Transform the data with parsed values
|
||||||
|
const transformedAnalysis = {
|
||||||
|
...parsedAnalysis[0],
|
||||||
|
total_spend_by_category: parsedAnalysis.map(item => ({
|
||||||
|
id: item.categories,
|
||||||
|
category: item.categories,
|
||||||
|
total_spend: Number(item.total_spend)
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(transformedAnalysis);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching cost analysis:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch cost analysis' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get receiving status metrics
|
||||||
|
router.get('/receiving-status', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const [status] = await pool.query(`
|
||||||
|
WITH po_totals AS (
|
||||||
|
SELECT
|
||||||
|
po_id,
|
||||||
|
SUM(ordered) as total_ordered,
|
||||||
|
SUM(received) as total_received,
|
||||||
|
SUM(ordered * cost_price) as total_cost
|
||||||
|
FROM purchase_orders
|
||||||
|
GROUP BY po_id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
COUNT(DISTINCT po_id) as order_count,
|
||||||
|
SUM(total_ordered) as total_ordered,
|
||||||
|
SUM(total_received) as total_received,
|
||||||
|
ROUND(
|
||||||
|
SUM(total_received) / NULLIF(SUM(total_ordered), 0), 3
|
||||||
|
) as fulfillment_rate,
|
||||||
|
SUM(total_cost) as total_value,
|
||||||
|
ROUND(AVG(total_cost), 2) as avg_cost
|
||||||
|
FROM po_totals
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Parse numeric values
|
||||||
|
const parsedStatus = {
|
||||||
|
order_count: Number(status[0].order_count) || 0,
|
||||||
|
total_ordered: Number(status[0].total_ordered) || 0,
|
||||||
|
total_received: Number(status[0].total_received) || 0,
|
||||||
|
fulfillment_rate: Number(status[0].fulfillment_rate) || 0,
|
||||||
|
total_value: Number(status[0].total_value) || 0,
|
||||||
|
avg_cost: Number(status[0].avg_cost) || 0
|
||||||
|
};
|
||||||
|
|
||||||
|
res.json(parsedStatus);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching receiving status:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch receiving status' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get order vs received quantities by product
|
||||||
|
router.get('/order-vs-received', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const pool = req.app.locals.pool;
|
||||||
|
|
||||||
|
const [quantities] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
p.product_id,
|
||||||
|
p.title as product,
|
||||||
|
p.SKU as sku,
|
||||||
|
SUM(po.ordered) as ordered_quantity,
|
||||||
|
SUM(po.received) as received_quantity,
|
||||||
|
ROUND(
|
||||||
|
SUM(po.received) / NULLIF(SUM(po.ordered), 0) * 100, 1
|
||||||
|
) as fulfillment_rate,
|
||||||
|
COUNT(DISTINCT po.po_id) as order_count
|
||||||
|
FROM products p
|
||||||
|
JOIN purchase_orders po ON p.product_id = po.product_id
|
||||||
|
WHERE po.date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
|
||||||
|
GROUP BY p.product_id, p.title, p.SKU
|
||||||
|
HAVING order_count > 0
|
||||||
|
ORDER BY ordered_quantity DESC
|
||||||
|
LIMIT 20
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Parse numeric values and add id for React keys
|
||||||
|
const parsedQuantities = quantities.map(q => ({
|
||||||
|
id: q.product_id,
|
||||||
|
...q,
|
||||||
|
ordered_quantity: Number(q.ordered_quantity),
|
||||||
|
received_quantity: Number(q.received_quantity),
|
||||||
|
fulfillment_rate: Number(q.fulfillment_rate),
|
||||||
|
order_count: Number(q.order_count)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(parsedQuantities);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching order vs received quantities:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch order vs received quantities' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -9,6 +9,7 @@ const dashboardRouter = require('./routes/dashboard');
|
|||||||
const ordersRouter = require('./routes/orders');
|
const ordersRouter = require('./routes/orders');
|
||||||
const csvRouter = require('./routes/csv');
|
const csvRouter = require('./routes/csv');
|
||||||
const analyticsRouter = require('./routes/analytics');
|
const analyticsRouter = require('./routes/analytics');
|
||||||
|
const purchaseOrdersRouter = require('./routes/purchase-orders');
|
||||||
|
|
||||||
// Get the absolute path to the .env file
|
// Get the absolute path to the .env file
|
||||||
const envPath = path.resolve(process.cwd(), '.env');
|
const envPath = path.resolve(process.cwd(), '.env');
|
||||||
@@ -79,6 +80,7 @@ app.use('/api/dashboard', dashboardRouter);
|
|||||||
app.use('/api/orders', ordersRouter);
|
app.use('/api/orders', ordersRouter);
|
||||||
app.use('/api/csv', csvRouter);
|
app.use('/api/csv', csvRouter);
|
||||||
app.use('/api/analytics', analyticsRouter);
|
app.use('/api/analytics', analyticsRouter);
|
||||||
|
app.use('/api/purchase-orders', purchaseOrdersRouter);
|
||||||
|
|
||||||
// Basic health check route
|
// Basic health check route
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Orders } from './pages/Orders';
|
|||||||
import { Settings } from './pages/Settings';
|
import { Settings } from './pages/Settings';
|
||||||
import { Analytics } from './pages/Analytics';
|
import { Analytics } from './pages/Analytics';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
|
import PurchaseOrders from './pages/PurchaseOrders';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ function App() {
|
|||||||
<Route path="/products" element={<Products />} />
|
<Route path="/products" element={<Products />} />
|
||||||
<Route path="/import" element={<Import />} />
|
<Route path="/import" element={<Import />} />
|
||||||
<Route path="/orders" element={<Orders />} />
|
<Route path="/orders" element={<Orders />} />
|
||||||
|
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
@@ -30,5 +32,5 @@ function App() {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -30,6 +30,11 @@ const items = [
|
|||||||
icon: ShoppingCart,
|
icon: ShoppingCart,
|
||||||
url: "/orders",
|
url: "/orders",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Purchase Orders",
|
||||||
|
icon: ClipboardList,
|
||||||
|
url: "/purchase-orders",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Analytics",
|
title: "Analytics",
|
||||||
icon: BarChart2,
|
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