Rearrange vendors and purchase orders page content, fix pagination
This commit is contained in:
@@ -15,39 +15,43 @@ router.get('/', async (req, res) => {
|
|||||||
const sortDirection = req.query.sortDirection || 'asc';
|
const sortDirection = req.query.sortDirection || 'asc';
|
||||||
|
|
||||||
// Build the WHERE clause based on filters
|
// Build the WHERE clause based on filters
|
||||||
const whereConditions = [];
|
const whereConditions = ['p.vendor IS NOT NULL AND p.vendor != \'\''];
|
||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereConditions.push('(LOWER(p.vendor) LIKE LOWER(?) OR LOWER(vd.contact_name) LIKE LOWER(?))');
|
whereConditions.push('LOWER(p.vendor) LIKE LOWER(?)');
|
||||||
params.push(`%${search}%`, `%${search}%`);
|
params.push(`%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (status !== 'all') {
|
if (status !== 'all') {
|
||||||
whereConditions.push('vm.status = ?');
|
whereConditions.push(`
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 THEN 'active'
|
||||||
|
WHEN COALESCE(vm.total_orders, 0) > 0 THEN 'inactive'
|
||||||
|
ELSE 'pending'
|
||||||
|
END = ?
|
||||||
|
`);
|
||||||
params.push(status);
|
params.push(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (performance !== 'all') {
|
if (performance !== 'all') {
|
||||||
switch (performance) {
|
switch (performance) {
|
||||||
case 'excellent':
|
case 'excellent':
|
||||||
whereConditions.push('vm.order_fill_rate >= 95');
|
whereConditions.push('COALESCE(vm.order_fill_rate, 0) >= 95');
|
||||||
break;
|
break;
|
||||||
case 'good':
|
case 'good':
|
||||||
whereConditions.push('vm.order_fill_rate >= 85 AND vm.order_fill_rate < 95');
|
whereConditions.push('COALESCE(vm.order_fill_rate, 0) >= 85 AND COALESCE(vm.order_fill_rate, 0) < 95');
|
||||||
break;
|
break;
|
||||||
case 'fair':
|
case 'fair':
|
||||||
whereConditions.push('vm.order_fill_rate >= 75 AND vm.order_fill_rate < 85');
|
whereConditions.push('COALESCE(vm.order_fill_rate, 0) >= 75 AND COALESCE(vm.order_fill_rate, 0) < 85');
|
||||||
break;
|
break;
|
||||||
case 'poor':
|
case 'poor':
|
||||||
whereConditions.push('vm.order_fill_rate < 75');
|
whereConditions.push('COALESCE(vm.order_fill_rate, 0) < 75');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0
|
const whereClause = 'WHERE ' + whereConditions.join(' AND ');
|
||||||
? 'WHERE ' + whereConditions.join(' AND ')
|
|
||||||
: '';
|
|
||||||
|
|
||||||
// Get total count for pagination
|
// Get total count for pagination
|
||||||
const [countResult] = await pool.query(`
|
const [countResult] = await pool.query(`
|
||||||
@@ -59,57 +63,105 @@ router.get('/', async (req, res) => {
|
|||||||
|
|
||||||
// Get vendors with metrics
|
// Get vendors with metrics
|
||||||
const [vendors] = await pool.query(`
|
const [vendors] = await pool.query(`
|
||||||
SELECT
|
SELECT DISTINCT
|
||||||
p.vendor as name,
|
p.vendor as name,
|
||||||
vd.contact_name,
|
COALESCE(vm.active_products, 0) as active_products,
|
||||||
vd.email,
|
COALESCE(vm.total_orders, 0) as total_orders,
|
||||||
vd.phone,
|
COALESCE(vm.avg_lead_time_days, 0) as avg_lead_time_days,
|
||||||
vm.status,
|
COALESCE(vm.on_time_delivery_rate, 0) as on_time_delivery_rate,
|
||||||
vm.avg_lead_time_days,
|
COALESCE(vm.order_fill_rate, 0) as order_fill_rate,
|
||||||
vm.on_time_delivery_rate,
|
CASE
|
||||||
vm.order_fill_rate,
|
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75 THEN 'active'
|
||||||
vm.total_orders,
|
WHEN COALESCE(vm.total_orders, 0) > 0 THEN 'inactive'
|
||||||
COUNT(DISTINCT p.product_id) as active_products
|
ELSE 'pending'
|
||||||
|
END as status
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
||||||
LEFT JOIN vendor_details vd ON p.vendor = vd.vendor
|
|
||||||
${whereClause}
|
${whereClause}
|
||||||
GROUP BY p.vendor
|
AND p.vendor IS NOT NULL AND p.vendor != ''
|
||||||
ORDER BY ${sortColumn} ${sortDirection}
|
ORDER BY ${sortColumn} ${sortDirection}
|
||||||
LIMIT ? OFFSET ?
|
LIMIT ? OFFSET ?
|
||||||
`, [...params, limit, offset]);
|
`, [...params, limit, offset]);
|
||||||
|
|
||||||
|
// Get cost metrics for these vendors
|
||||||
|
const vendorNames = vendors.map(v => v.name);
|
||||||
|
const [costMetrics] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
vendor,
|
||||||
|
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
||||||
|
SUM(ordered * cost_price) as total_spend
|
||||||
|
FROM purchase_orders
|
||||||
|
WHERE status = 'closed'
|
||||||
|
AND cost_price IS NOT NULL
|
||||||
|
AND ordered > 0
|
||||||
|
AND vendor IN (?)
|
||||||
|
GROUP BY vendor
|
||||||
|
`, [vendorNames]);
|
||||||
|
|
||||||
|
// Create a map of cost metrics by vendor
|
||||||
|
const costMetricsMap = costMetrics.reduce((acc, curr) => {
|
||||||
|
acc[curr.vendor] = {
|
||||||
|
avg_unit_cost: curr.avg_unit_cost,
|
||||||
|
total_spend: curr.total_spend
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
// Get overall stats
|
// Get overall stats
|
||||||
const [stats] = await pool.query(`
|
const [stats] = await pool.query(`
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(DISTINCT p.vendor) as totalVendors,
|
COUNT(DISTINCT p.vendor) as totalVendors,
|
||||||
COUNT(DISTINCT CASE WHEN vm.status = 'active' THEN p.vendor END) as activeVendors,
|
COUNT(DISTINCT CASE
|
||||||
COALESCE(ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1), 0) as avgLeadTime,
|
WHEN COALESCE(vm.total_orders, 0) > 0 AND COALESCE(vm.order_fill_rate, 0) >= 75
|
||||||
COALESCE(ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1), 0) as avgFillRate,
|
THEN p.vendor
|
||||||
COALESCE(ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1), 0) as avgOnTimeDelivery
|
END) as activeVendors,
|
||||||
|
ROUND(AVG(NULLIF(vm.avg_lead_time_days, 0)), 1) as avgLeadTime,
|
||||||
|
ROUND(AVG(NULLIF(vm.order_fill_rate, 0)), 1) as avgFillRate,
|
||||||
|
ROUND(AVG(NULLIF(vm.on_time_delivery_rate, 0)), 1) as avgOnTimeDelivery
|
||||||
FROM products p
|
FROM products p
|
||||||
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
LEFT JOIN vendor_metrics vm ON p.vendor = vm.vendor
|
||||||
|
WHERE p.vendor IS NOT NULL AND p.vendor != ''
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Get overall cost metrics
|
||||||
|
const [overallCostMetrics] = await pool.query(`
|
||||||
|
SELECT
|
||||||
|
ROUND(SUM(ordered * cost_price) / NULLIF(SUM(ordered), 0), 2) as avg_unit_cost,
|
||||||
|
SUM(ordered * cost_price) as total_spend
|
||||||
|
FROM purchase_orders
|
||||||
|
WHERE status = 'closed'
|
||||||
|
AND cost_price IS NOT NULL
|
||||||
|
AND ordered > 0
|
||||||
|
AND vendor IS NOT NULL AND vendor != ''
|
||||||
`);
|
`);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
vendors: vendors.map(vendor => ({
|
vendors: vendors.map(vendor => ({
|
||||||
...vendor,
|
vendor_id: vendor.vendor_id || vendor.name,
|
||||||
|
name: vendor.name,
|
||||||
|
status: vendor.status,
|
||||||
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days || 0),
|
avg_lead_time_days: parseFloat(vendor.avg_lead_time_days || 0),
|
||||||
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate || 0),
|
on_time_delivery_rate: parseFloat(vendor.on_time_delivery_rate || 0),
|
||||||
order_fill_rate: parseFloat(vendor.order_fill_rate || 0),
|
order_fill_rate: parseFloat(vendor.order_fill_rate || 0),
|
||||||
total_orders: parseInt(vendor.total_orders || 0),
|
total_orders: parseInt(vendor.total_orders || 0),
|
||||||
active_products: parseInt(vendor.active_products || 0)
|
active_products: parseInt(vendor.active_products || 0),
|
||||||
|
avg_unit_cost: parseFloat(costMetricsMap[vendor.name]?.avg_unit_cost || 0),
|
||||||
|
total_spend: parseFloat(costMetricsMap[vendor.name]?.total_spend || 0)
|
||||||
})),
|
})),
|
||||||
stats: {
|
stats: {
|
||||||
...stats[0],
|
totalVendors: parseInt(stats[0].totalVendors || 0),
|
||||||
|
activeVendors: parseInt(stats[0].activeVendors || 0),
|
||||||
avgLeadTime: parseFloat(stats[0].avgLeadTime || 0),
|
avgLeadTime: parseFloat(stats[0].avgLeadTime || 0),
|
||||||
avgFillRate: parseFloat(stats[0].avgFillRate || 0),
|
avgFillRate: parseFloat(stats[0].avgFillRate || 0),
|
||||||
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0)
|
avgOnTimeDelivery: parseFloat(stats[0].avgOnTimeDelivery || 0),
|
||||||
|
avgUnitCost: parseFloat(overallCostMetrics[0].avg_unit_cost || 0),
|
||||||
|
totalSpend: parseFloat(overallCostMetrics[0].total_spend || 0)
|
||||||
},
|
},
|
||||||
pagination: {
|
pagination: {
|
||||||
total: countResult[0].total,
|
total: parseInt(countResult[0].total || 0),
|
||||||
pages: Math.ceil(countResult[0].total / limit),
|
currentPage: page,
|
||||||
current: page,
|
pages: Math.ceil(parseInt(countResult[0].total || 0) / limit),
|
||||||
|
limit
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function Categories() {
|
|||||||
parent: "all",
|
parent: "all",
|
||||||
performance: "all",
|
performance: "all",
|
||||||
});
|
});
|
||||||
const [sorting, setSorting] = useState({
|
const [] = useState({
|
||||||
column: 'name',
|
column: 'name',
|
||||||
direction: 'asc'
|
direction: 'asc'
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ interface PurchaseOrdersResponse {
|
|||||||
|
|
||||||
export default function PurchaseOrders() {
|
export default function PurchaseOrders() {
|
||||||
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
const [purchaseOrders, setPurchaseOrders] = useState<PurchaseOrder[]>([]);
|
||||||
const [vendorMetrics, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
const [, setVendorMetrics] = useState<VendorMetrics[]>([]);
|
||||||
const [costAnalysis, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
const [costAnalysis, setCostAnalysis] = useState<CostAnalysis | null>(null);
|
||||||
const [summary, setSummary] = useState<ReceivingStatus | null>(null);
|
const [summary, setSummary] = useState<ReceivingStatus | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -391,39 +391,6 @@ export default function PurchaseOrders() {
|
|||||||
</div>
|
</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.toLocaleString()}</TableCell>
|
|
||||||
<TableCell>{vendor.avg_delivery_days?.toFixed(1) || 'N/A'}</TableCell>
|
|
||||||
<TableCell>{formatPercent(vendor.fulfillment_rate)}</TableCell>
|
|
||||||
<TableCell>${formatNumber(vendor.avg_unit_cost)}</TableCell>
|
|
||||||
<TableCell>${formatNumber(vendor.total_spend)}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Cost Analysis */}
|
{/* Cost Analysis */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
@@ -12,15 +12,14 @@ import config from "../config";
|
|||||||
interface Vendor {
|
interface Vendor {
|
||||||
vendor_id: number;
|
vendor_id: number;
|
||||||
name: string;
|
name: string;
|
||||||
contact_name: string;
|
|
||||||
email: string;
|
|
||||||
phone: string;
|
|
||||||
status: string;
|
status: string;
|
||||||
avg_lead_time_days: number;
|
avg_lead_time_days: number;
|
||||||
on_time_delivery_rate: number;
|
on_time_delivery_rate: number;
|
||||||
order_fill_rate: number;
|
order_fill_rate: number;
|
||||||
total_orders: number;
|
total_orders: number;
|
||||||
active_products: number;
|
active_products: number;
|
||||||
|
avg_unit_cost: number;
|
||||||
|
total_spend: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VendorFilters {
|
interface VendorFilters {
|
||||||
@@ -38,15 +37,26 @@ export function Vendors() {
|
|||||||
status: "all",
|
status: "all",
|
||||||
performance: "all",
|
performance: "all",
|
||||||
});
|
});
|
||||||
const [sorting, setSorting] = useState({
|
const [] = useState({
|
||||||
column: 'name',
|
column: 'name',
|
||||||
direction: 'asc'
|
direction: 'asc'
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["vendors"],
|
queryKey: ["vendors", page, filters, sortColumn, sortDirection],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const response = await fetch(`${config.apiUrl}/vendors`);
|
const params = new URLSearchParams({
|
||||||
|
page: page.toString(),
|
||||||
|
limit: '50',
|
||||||
|
search: filters.search,
|
||||||
|
status: filters.status,
|
||||||
|
performance: filters.performance,
|
||||||
|
sortColumn,
|
||||||
|
sortDirection
|
||||||
|
});
|
||||||
|
const response = await fetch(`${config.apiUrl}/vendors?${params}`, {
|
||||||
|
credentials: 'include'
|
||||||
|
});
|
||||||
if (!response.ok) throw new Error("Failed to fetch vendors");
|
if (!response.ok) throw new Error("Failed to fetch vendors");
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
@@ -62,9 +72,7 @@ export function Vendors() {
|
|||||||
if (filters.search) {
|
if (filters.search) {
|
||||||
const searchLower = filters.search.toLowerCase();
|
const searchLower = filters.search.toLowerCase();
|
||||||
filtered = filtered.filter(vendor =>
|
filtered = filtered.filter(vendor =>
|
||||||
vendor.name.toLowerCase().includes(searchLower) ||
|
vendor.name.toLowerCase().includes(searchLower)
|
||||||
vendor.contact_name?.toLowerCase().includes(searchLower) ||
|
|
||||||
vendor.email?.toLowerCase().includes(searchLower)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,27 +116,15 @@ export function Vendors() {
|
|||||||
|
|
||||||
// Calculate pagination
|
// Calculate pagination
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
const startIndex = (page - 1) * 50;
|
if (!data?.vendors) return [];
|
||||||
return filteredData.slice(startIndex, startIndex + 50);
|
return data.vendors;
|
||||||
}, [filteredData, page]);
|
}, [data?.vendors]);
|
||||||
|
|
||||||
// Calculate stats from filtered data
|
// Calculate stats from filtered data
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!filteredData.length) return data?.stats;
|
if (!data?.stats) return null;
|
||||||
|
return data.stats;
|
||||||
const activeVendors = filteredData.filter(v => v.status === 'active').length;
|
}, [data?.stats]);
|
||||||
const leadTimes = filteredData.map(v => v.avg_lead_time_days || 0).filter(lt => lt !== 0);
|
|
||||||
const fillRates = filteredData.map(v => v.order_fill_rate || 0).filter(fr => fr !== 0);
|
|
||||||
const onTimeRates = filteredData.map(v => v.on_time_delivery_rate || 0).filter(otr => otr !== 0);
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalVendors: filteredData.length,
|
|
||||||
activeVendors,
|
|
||||||
avgLeadTime: leadTimes.length ? leadTimes.reduce((a, b) => a + b, 0) / leadTimes.length : 0,
|
|
||||||
avgFillRate: fillRates.length ? fillRates.reduce((a, b) => a + b, 0) / fillRates.length : 0,
|
|
||||||
avgOnTimeDelivery: onTimeRates.length ? onTimeRates.reduce((a, b) => a + b, 0) / onTimeRates.length : 0
|
|
||||||
};
|
|
||||||
}, [filteredData, data?.stats]);
|
|
||||||
|
|
||||||
const handleSort = (column: keyof Vendor) => {
|
const handleSort = (column: keyof Vendor) => {
|
||||||
setSortDirection(prev => {
|
setSortDirection(prev => {
|
||||||
@@ -186,36 +182,38 @@ export function Vendors() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Avg Lead Time</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Spend</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{typeof stats?.avgLeadTime === 'number' ? stats.avgLeadTime.toFixed(1) : "..."} days</div>
|
<div className="text-2xl font-bold">
|
||||||
|
${typeof stats?.totalSpend === 'number' ? stats.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "..."}
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Across all vendors
|
Avg unit cost: ${typeof stats?.avgUnitCost === 'number' ? stats.avgUnitCost.toFixed(2) : "..."}
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">Fill Rate</CardTitle>
|
<CardTitle className="text-sm font-medium">Performance</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{typeof stats?.avgFillRate === 'number' ? stats.avgFillRate.toFixed(1) : "..."}%</div>
|
<div className="text-2xl font-bold">{typeof stats?.avgFillRate === 'number' ? stats.avgFillRate.toFixed(1) : "..."}%</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Average order fill rate
|
Fill rate / {typeof stats?.avgOnTimeDelivery === 'number' ? stats.avgOnTimeDelivery.toFixed(1) : "..."}% on-time
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<CardTitle className="text-sm font-medium">On-Time Delivery</CardTitle>
|
<CardTitle className="text-sm font-medium">Lead Time</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{typeof stats?.avgOnTimeDelivery === 'number' ? stats.avgOnTimeDelivery.toFixed(1) : "..."}%</div>
|
<div className="text-2xl font-bold">{typeof stats?.avgLeadTime === 'number' ? stats.avgLeadTime.toFixed(1) : "..."} days</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Average on-time rate
|
Average delivery time
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -265,12 +263,13 @@ export function Vendors() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Name</TableHead>
|
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Vendor</TableHead>
|
||||||
<TableHead onClick={() => handleSort("contact_name")} className="cursor-pointer">Contact</TableHead>
|
|
||||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead>
|
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead>
|
||||||
<TableHead onClick={() => handleSort("avg_lead_time_days")} className="cursor-pointer">Lead Time</TableHead>
|
<TableHead onClick={() => handleSort("avg_lead_time_days")} className="cursor-pointer">Lead Time</TableHead>
|
||||||
<TableHead onClick={() => handleSort("on_time_delivery_rate")} className="cursor-pointer">On-Time Rate</TableHead>
|
<TableHead onClick={() => handleSort("on_time_delivery_rate")} className="cursor-pointer">On-Time %</TableHead>
|
||||||
<TableHead onClick={() => handleSort("order_fill_rate")} className="cursor-pointer">Fill Rate</TableHead>
|
<TableHead onClick={() => handleSort("order_fill_rate")} className="cursor-pointer">Fill Rate</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("avg_unit_cost")} className="cursor-pointer">Avg Unit Cost</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("total_spend")} className="cursor-pointer">Total Spend</TableHead>
|
||||||
<TableHead onClick={() => handleSort("total_orders")} className="cursor-pointer">Orders</TableHead>
|
<TableHead onClick={() => handleSort("total_orders")} className="cursor-pointer">Orders</TableHead>
|
||||||
<TableHead onClick={() => handleSort("active_products")} className="cursor-pointer">Products</TableHead>
|
<TableHead onClick={() => handleSort("active_products")} className="cursor-pointer">Products</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -278,17 +277,13 @@ export function Vendors() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center py-8">
|
<TableCell colSpan={9} className="text-center py-8">
|
||||||
Loading vendors...
|
Loading vendors...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : paginatedData.map((vendor: Vendor) => (
|
) : paginatedData.map((vendor: Vendor) => (
|
||||||
<TableRow key={vendor.vendor_id}>
|
<TableRow key={vendor.vendor_id}>
|
||||||
<TableCell className="font-medium">{vendor.name}</TableCell>
|
<TableCell className="font-medium">{vendor.name}</TableCell>
|
||||||
<TableCell>
|
|
||||||
<div>{vendor.contact_name}</div>
|
|
||||||
<div className="text-sm text-muted-foreground">{vendor.email}</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{vendor.status}</TableCell>
|
<TableCell>{vendor.status}</TableCell>
|
||||||
<TableCell>{typeof vendor.avg_lead_time_days === 'number' ? vendor.avg_lead_time_days.toFixed(1) : "0.0"} days</TableCell>
|
<TableCell>{typeof vendor.avg_lead_time_days === 'number' ? vendor.avg_lead_time_days.toFixed(1) : "0.0"} days</TableCell>
|
||||||
<TableCell>{typeof vendor.on_time_delivery_rate === 'number' ? vendor.on_time_delivery_rate.toFixed(1) : "0.0"}%</TableCell>
|
<TableCell>{typeof vendor.on_time_delivery_rate === 'number' ? vendor.on_time_delivery_rate.toFixed(1) : "0.0"}%</TableCell>
|
||||||
@@ -300,13 +295,15 @@ export function Vendors() {
|
|||||||
{getPerformanceBadge(vendor.order_fill_rate ?? 0)}
|
{getPerformanceBadge(vendor.order_fill_rate ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>${typeof vendor.avg_unit_cost === 'number' ? vendor.avg_unit_cost.toFixed(2) : "0.00"}</TableCell>
|
||||||
|
<TableCell>${typeof vendor.total_spend === 'number' ? vendor.total_spend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "0"}</TableCell>
|
||||||
<TableCell>{vendor.total_orders?.toLocaleString() ?? 0}</TableCell>
|
<TableCell>{vendor.total_orders?.toLocaleString() ?? 0}</TableCell>
|
||||||
<TableCell>{vendor.active_products?.toLocaleString() ?? 0}</TableCell>
|
<TableCell>{vendor.active_products?.toLocaleString() ?? 0}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!isLoading && !paginatedData.length && (
|
{!isLoading && !paginatedData.length && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||||
No vendors found
|
No vendors found
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -315,7 +312,7 @@ export function Vendors() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{filteredData.length > 0 && (
|
{data?.pagination && data.pagination.total > 0 && (
|
||||||
<motion.div
|
<motion.div
|
||||||
layout="position"
|
layout="position"
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
@@ -333,7 +330,7 @@ export function Vendors() {
|
|||||||
aria-disabled={page === 1}
|
aria-disabled={page === 1}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{Array.from({ length: Math.ceil(filteredData.length / 50) }, (_, i) => (
|
{Array.from({ length: data.pagination.pages }, (_, i) => (
|
||||||
<PaginationItem key={i + 1}>
|
<PaginationItem key={i + 1}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
href="#"
|
href="#"
|
||||||
@@ -352,9 +349,9 @@ export function Vendors() {
|
|||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (page < Math.ceil(filteredData.length / 50)) setPage(p => p + 1);
|
if (page < data.pagination.pages) setPage(p => p + 1);
|
||||||
}}
|
}}
|
||||||
aria-disabled={page >= Math.ceil(filteredData.length / 50)}
|
aria-disabled={page >= data.pagination.pages}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
|
|||||||
Reference in New Issue
Block a user