Align badges and fix search
This commit is contained in:
@@ -19,7 +19,7 @@ router.get('/', async (req, res) => {
|
|||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereConditions.push('(c.name LIKE ? OR c.description LIKE ?)');
|
whereConditions.push('(LOWER(c.name) LIKE LOWER(?) OR LOWER(c.description) LIKE LOWER(?))');
|
||||||
params.push(`%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ router.get('/', async (req, res) => {
|
|||||||
const params = [];
|
const params = [];
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
whereConditions.push('(p.vendor LIKE ? OR vd.contact_name LIKE ?)');
|
whereConditions.push('(LOWER(p.vendor) LIKE LOWER(?) OR LOWER(vd.contact_name) LIKE LOWER(?))');
|
||||||
params.push(`%${search}%`, `%${search}%`);
|
params.push(`%${search}%`, `%${search}%`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
@@ -37,27 +37,101 @@ export function Categories() {
|
|||||||
parent: "all",
|
parent: "all",
|
||||||
performance: "all",
|
performance: "all",
|
||||||
});
|
});
|
||||||
|
const [sorting, setSorting] = useState({
|
||||||
|
column: 'name',
|
||||||
|
direction: 'asc'
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["categories", page, sortColumn, sortDirection, filters],
|
queryKey: ["categories"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const searchParams = new URLSearchParams({
|
const response = await fetch(`${config.apiUrl}/categories`);
|
||||||
page: page.toString(),
|
|
||||||
limit: "50",
|
|
||||||
sortColumn: sortColumn.toString(),
|
|
||||||
sortDirection,
|
|
||||||
...filters.search && { search: filters.search },
|
|
||||||
...filters.parent !== "all" && { parent: filters.parent },
|
|
||||||
...filters.performance !== "all" && { performance: filters.performance },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`${config.apiUrl}/categories?${searchParams}`);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch categories");
|
if (!response.ok) throw new Error("Failed to fetch categories");
|
||||||
return response.json();
|
return response.json();
|
||||||
},
|
},
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter and sort the data client-side
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!data?.categories) return [];
|
||||||
|
|
||||||
|
let filtered = [...data.categories];
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
filtered = filtered.filter(category =>
|
||||||
|
category.name.toLowerCase().includes(searchLower) ||
|
||||||
|
(category.description?.toLowerCase().includes(searchLower))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply parent filter
|
||||||
|
if (filters.parent !== 'all') {
|
||||||
|
if (filters.parent === 'none') {
|
||||||
|
filtered = filtered.filter(category => !category.parent_category);
|
||||||
|
} else {
|
||||||
|
filtered = filtered.filter(category => category.parent_category === filters.parent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply performance filter
|
||||||
|
if (filters.performance !== 'all') {
|
||||||
|
filtered = filtered.filter(category => {
|
||||||
|
const growth = category.growth_rate ?? 0;
|
||||||
|
switch (filters.performance) {
|
||||||
|
case 'high_growth': return growth >= 20;
|
||||||
|
case 'growing': return growth >= 5 && growth < 20;
|
||||||
|
case 'stable': return growth >= -5 && growth < 5;
|
||||||
|
case 'declining': return growth < -5;
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aVal = a[sortColumn];
|
||||||
|
const bVal = b[sortColumn];
|
||||||
|
|
||||||
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||||
|
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aStr = String(aVal || '');
|
||||||
|
const bStr = String(bVal || '');
|
||||||
|
return sortDirection === 'asc' ?
|
||||||
|
aStr.localeCompare(bStr) :
|
||||||
|
bStr.localeCompare(aStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [data?.categories, filters, sortColumn, sortDirection]);
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const startIndex = (page - 1) * 50;
|
||||||
|
return filteredData.slice(startIndex, startIndex + 50);
|
||||||
|
}, [filteredData, page]);
|
||||||
|
|
||||||
|
// Calculate stats from filtered data
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!filteredData.length) return data?.stats;
|
||||||
|
|
||||||
|
const activeCategories = filteredData.filter(c => c.status === 'active').length;
|
||||||
|
const totalValue = filteredData.reduce((sum, c) => sum + (c.total_value || 0), 0);
|
||||||
|
const margins = filteredData.map(c => c.avg_margin || 0).filter(m => m !== 0);
|
||||||
|
const growthRates = filteredData.map(c => c.growth_rate || 0).filter(g => g !== 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalCategories: filteredData.length,
|
||||||
|
activeCategories,
|
||||||
|
totalValue,
|
||||||
|
avgMargin: margins.length ? margins.reduce((a, b) => a + b, 0) / margins.length : 0,
|
||||||
|
avgGrowth: growthRates.length ? growthRates.reduce((a, b) => a + b, 0) / growthRates.length : 0
|
||||||
|
};
|
||||||
|
}, [filteredData, data?.stats]);
|
||||||
|
|
||||||
const handleSort = (column: keyof Category) => {
|
const handleSort = (column: keyof Category) => {
|
||||||
setSortDirection(prev => {
|
setSortDirection(prev => {
|
||||||
if (sortColumn !== column) return "asc";
|
if (sortColumn !== column) return "asc";
|
||||||
@@ -83,23 +157,40 @@ export function Categories() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div layout className="container mx-auto py-6 space-y-4">
|
<motion.div
|
||||||
<div className="flex items-center justify-between">
|
layout
|
||||||
|
transition={{
|
||||||
|
layout: {
|
||||||
|
duration: 0.15,
|
||||||
|
ease: [0.4, 0, 0.2, 1] // Material Design easing
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="container mx-auto py-6 space-y-4"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{data?.pagination.total.toLocaleString() ?? "..."} categories
|
{filteredData.length.toLocaleString()} categories
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<motion.div
|
||||||
|
layout="preserve-aspect"
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="grid gap-4 md:grid-cols-4"
|
||||||
|
>
|
||||||
<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">Total Categories</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Categories</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{data?.stats?.totalCategories ?? "..."}</div>
|
<div className="text-2xl font-bold">{stats?.totalCategories ?? "..."}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{data?.stats?.activeCategories ?? "..."} active
|
{stats?.activeCategories ?? "..."} active
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -139,7 +230,7 @@ export function Categories() {
|
|||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-1 items-center space-x-2">
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
@@ -203,7 +294,7 @@ export function Categories() {
|
|||||||
Loading categories...
|
Loading categories...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : data?.categories?.map((category: Category) => (
|
) : paginatedData.map((category: Category) => (
|
||||||
<TableRow key={category.category_id}>
|
<TableRow key={category.category_id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium">{category.name}</div>
|
<div className="font-medium">{category.name}</div>
|
||||||
@@ -215,15 +306,17 @@ export function Categories() {
|
|||||||
<TableCell>{typeof category.avg_margin === 'number' ? category.avg_margin.toFixed(1) : "0.0"}%</TableCell>
|
<TableCell>{typeof category.avg_margin === 'number' ? category.avg_margin.toFixed(1) : "0.0"}%</TableCell>
|
||||||
<TableCell>{typeof category.turnover_rate === 'number' ? category.turnover_rate.toFixed(1) : "0.0"}x</TableCell>
|
<TableCell>{typeof category.turnover_rate === 'number' ? category.turnover_rate.toFixed(1) : "0.0"}x</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}>
|
||||||
{typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}%
|
<div style={{ width: '50px', textAlign: 'right' }}>
|
||||||
|
{typeof category.growth_rate === 'number' ? category.growth_rate.toFixed(1) : "0.0"}%
|
||||||
|
</div>
|
||||||
{getPerformanceBadge(category.growth_rate ?? 0)}
|
{getPerformanceBadge(category.growth_rate ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{category.status}</TableCell>
|
<TableCell>{category.status}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!isLoading && !data?.categories.length && (
|
{!isLoading && !paginatedData.length && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||||
No categories found
|
No categories found
|
||||||
@@ -234,8 +327,12 @@ export function Categories() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data?.pagination.pages > 1 && (
|
{filteredData.length > 0 && (
|
||||||
<div className="flex justify-center">
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="flex justify-center"
|
||||||
|
>
|
||||||
<Pagination>
|
<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
@@ -243,22 +340,22 @@ export function Categories() {
|
|||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(p => Math.max(1, p - 1));
|
if (page > 1) setPage(p => p - 1);
|
||||||
}}
|
}}
|
||||||
aria-disabled={page === 1}
|
aria-disabled={page === 1}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
|
{Array.from({ length: Math.ceil(filteredData.length / 50) }, (_, i) => (
|
||||||
<PaginationItem key={p}>
|
<PaginationItem key={i + 1}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
href="#"
|
href="#"
|
||||||
isActive={page === p}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(p);
|
setPage(i + 1);
|
||||||
}}
|
}}
|
||||||
|
isActive={page === i + 1}
|
||||||
>
|
>
|
||||||
{p}
|
{i + 1}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
@@ -267,14 +364,14 @@ export function Categories() {
|
|||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(p => Math.min(data.pagination.pages, p + 1));
|
if (page < Math.ceil(filteredData.length / 50)) setPage(p => p + 1);
|
||||||
}}
|
}}
|
||||||
aria-disabled={page === data.pagination.pages}
|
aria-disabled={page >= Math.ceil(filteredData.length / 50)}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
@@ -38,27 +38,98 @@ export function Vendors() {
|
|||||||
status: "all",
|
status: "all",
|
||||||
performance: "all",
|
performance: "all",
|
||||||
});
|
});
|
||||||
|
const [sorting, setSorting] = useState({
|
||||||
|
column: 'name',
|
||||||
|
direction: 'asc'
|
||||||
|
});
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ["vendors", page, sortColumn, sortDirection, filters],
|
queryKey: ["vendors"],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const searchParams = new URLSearchParams({
|
const response = await fetch(`${config.apiUrl}/vendors`);
|
||||||
page: page.toString(),
|
|
||||||
limit: "50",
|
|
||||||
sortColumn: sortColumn.toString(),
|
|
||||||
sortDirection,
|
|
||||||
...filters.search && { search: filters.search },
|
|
||||||
...filters.status !== "all" && { status: filters.status },
|
|
||||||
...filters.performance !== "all" && { performance: filters.performance },
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await fetch(`${config.apiUrl}/vendors?${searchParams}`);
|
|
||||||
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();
|
||||||
},
|
},
|
||||||
placeholderData: keepPreviousData,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Filter and sort the data client-side
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
if (!data?.vendors) return [];
|
||||||
|
|
||||||
|
let filtered = [...data.vendors];
|
||||||
|
|
||||||
|
// Apply search filter
|
||||||
|
if (filters.search) {
|
||||||
|
const searchLower = filters.search.toLowerCase();
|
||||||
|
filtered = filtered.filter(vendor =>
|
||||||
|
vendor.name.toLowerCase().includes(searchLower) ||
|
||||||
|
vendor.contact_name?.toLowerCase().includes(searchLower) ||
|
||||||
|
vendor.email?.toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply status filter
|
||||||
|
if (filters.status !== 'all') {
|
||||||
|
filtered = filtered.filter(vendor => vendor.status === filters.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply performance filter
|
||||||
|
if (filters.performance !== 'all') {
|
||||||
|
filtered = filtered.filter(vendor => {
|
||||||
|
const fillRate = vendor.order_fill_rate ?? 0;
|
||||||
|
switch (filters.performance) {
|
||||||
|
case 'excellent': return fillRate >= 95;
|
||||||
|
case 'good': return fillRate >= 85 && fillRate < 95;
|
||||||
|
case 'fair': return fillRate >= 75 && fillRate < 85;
|
||||||
|
case 'poor': return fillRate < 75;
|
||||||
|
default: return true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sorting
|
||||||
|
filtered.sort((a, b) => {
|
||||||
|
const aVal = a[sortColumn];
|
||||||
|
const bVal = b[sortColumn];
|
||||||
|
|
||||||
|
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||||||
|
return sortDirection === 'asc' ? aVal - bVal : bVal - aVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aStr = String(aVal || '');
|
||||||
|
const bStr = String(bVal || '');
|
||||||
|
return sortDirection === 'asc' ?
|
||||||
|
aStr.localeCompare(bStr) :
|
||||||
|
bStr.localeCompare(aStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
return filtered;
|
||||||
|
}, [data?.vendors, filters, sortColumn, sortDirection]);
|
||||||
|
|
||||||
|
// Calculate pagination
|
||||||
|
const paginatedData = useMemo(() => {
|
||||||
|
const startIndex = (page - 1) * 50;
|
||||||
|
return filteredData.slice(startIndex, startIndex + 50);
|
||||||
|
}, [filteredData, page]);
|
||||||
|
|
||||||
|
// Calculate stats from filtered data
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
if (!filteredData.length) return data?.stats;
|
||||||
|
|
||||||
|
const activeVendors = filteredData.filter(v => v.status === 'active').length;
|
||||||
|
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 => {
|
||||||
if (sortColumn !== column) return "asc";
|
if (sortColumn !== column) return "asc";
|
||||||
@@ -75,23 +146,40 @@ export function Vendors() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div layout className="container mx-auto py-6 space-y-4">
|
<motion.div
|
||||||
<div className="flex items-center justify-between">
|
layout
|
||||||
|
transition={{
|
||||||
|
layout: {
|
||||||
|
duration: 0.15,
|
||||||
|
ease: [0.4, 0, 0.2, 1] // Material Design easing
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="container mx-auto py-6 space-y-4"
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="flex items-center justify-between"
|
||||||
|
>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{data?.pagination.total.toLocaleString() ?? "..."} vendors
|
{filteredData.length.toLocaleString()} vendors
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<motion.div
|
||||||
|
layout="preserve-aspect"
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="grid gap-4 md:grid-cols-4"
|
||||||
|
>
|
||||||
<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">Total Vendors</CardTitle>
|
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{data?.stats?.totalVendors ?? "..."}</div>
|
<div className="text-2xl font-bold">{stats?.totalVendors ?? "..."}</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{data?.stats?.activeVendors ?? "..."} active
|
{stats?.activeVendors ?? "..."} active
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -101,7 +189,7 @@ export function Vendors() {
|
|||||||
<CardTitle className="text-sm font-medium">Avg Lead Time</CardTitle>
|
<CardTitle className="text-sm font-medium">Avg Lead Time</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgLeadTime === 'number' ? data.stats.avgLeadTime.toFixed(1) : "..."} days</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">
|
||||||
Across all vendors
|
Across all vendors
|
||||||
</p>
|
</p>
|
||||||
@@ -113,7 +201,7 @@ export function Vendors() {
|
|||||||
<CardTitle className="text-sm font-medium">Fill Rate</CardTitle>
|
<CardTitle className="text-sm font-medium">Fill Rate</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgFillRate === 'number' ? data.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
|
Average order fill rate
|
||||||
</p>
|
</p>
|
||||||
@@ -125,13 +213,13 @@ export function Vendors() {
|
|||||||
<CardTitle className="text-sm font-medium">On-Time Delivery</CardTitle>
|
<CardTitle className="text-sm font-medium">On-Time Delivery</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgOnTimeDelivery === 'number' ? data.stats.avgOnTimeDelivery.toFixed(1) : "..."}%</div>
|
<div className="text-2xl font-bold">{typeof stats?.avgOnTimeDelivery === 'number' ? stats.avgOnTimeDelivery.toFixed(1) : "..."}%</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Average on-time rate
|
Average on-time rate
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex flex-1 items-center space-x-2">
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
@@ -194,7 +282,7 @@ export function Vendors() {
|
|||||||
Loading vendors...
|
Loading vendors...
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : data?.vendors?.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>
|
<TableCell>
|
||||||
@@ -205,8 +293,10 @@ export function Vendors() {
|
|||||||
<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>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}>
|
||||||
{typeof vendor.order_fill_rate === 'number' ? vendor.order_fill_rate.toFixed(1) : "0.0"}%
|
<div style={{ width: '50px', textAlign: 'right' }}>
|
||||||
|
{typeof vendor.order_fill_rate === 'number' ? vendor.order_fill_rate.toFixed(1) : "0.0"}%
|
||||||
|
</div>
|
||||||
{getPerformanceBadge(vendor.order_fill_rate ?? 0)}
|
{getPerformanceBadge(vendor.order_fill_rate ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -214,7 +304,7 @@ export function Vendors() {
|
|||||||
<TableCell>{vendor.active_products?.toLocaleString() ?? 0}</TableCell>
|
<TableCell>{vendor.active_products?.toLocaleString() ?? 0}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!isLoading && !data?.vendors.length && (
|
{!isLoading && !paginatedData.length && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||||
No vendors found
|
No vendors found
|
||||||
@@ -225,8 +315,12 @@ export function Vendors() {
|
|||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data?.pagination.pages > 1 && (
|
{filteredData.length > 0 && (
|
||||||
<div className="flex justify-center">
|
<motion.div
|
||||||
|
layout="position"
|
||||||
|
transition={{ duration: 0.15 }}
|
||||||
|
className="flex justify-center"
|
||||||
|
>
|
||||||
<Pagination>
|
<Pagination>
|
||||||
<PaginationContent>
|
<PaginationContent>
|
||||||
<PaginationItem>
|
<PaginationItem>
|
||||||
@@ -234,22 +328,22 @@ export function Vendors() {
|
|||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(p => Math.max(1, p - 1));
|
if (page > 1) setPage(p => p - 1);
|
||||||
}}
|
}}
|
||||||
aria-disabled={page === 1}
|
aria-disabled={page === 1}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
|
{Array.from({ length: Math.ceil(filteredData.length / 50) }, (_, i) => (
|
||||||
<PaginationItem key={p}>
|
<PaginationItem key={i + 1}>
|
||||||
<PaginationLink
|
<PaginationLink
|
||||||
href="#"
|
href="#"
|
||||||
isActive={page === p}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(p);
|
setPage(i + 1);
|
||||||
}}
|
}}
|
||||||
|
isActive={page === i + 1}
|
||||||
>
|
>
|
||||||
{p}
|
{i + 1}
|
||||||
</PaginationLink>
|
</PaginationLink>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
))}
|
))}
|
||||||
@@ -258,14 +352,14 @@ export function Vendors() {
|
|||||||
href="#"
|
href="#"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(p => Math.min(data.pagination.pages, p + 1));
|
if (page < Math.ceil(filteredData.length / 50)) setPage(p => p + 1);
|
||||||
}}
|
}}
|
||||||
aria-disabled={page === data.pagination.pages}
|
aria-disabled={page >= Math.ceil(filteredData.length / 50)}
|
||||||
/>
|
/>
|
||||||
</PaginationItem>
|
</PaginationItem>
|
||||||
</PaginationContent>
|
</PaginationContent>
|
||||||
</Pagination>
|
</Pagination>
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user