Add frontend changes
This commit is contained in:
@@ -13,6 +13,8 @@ import { useEffect } from 'react';
|
|||||||
import config from './config';
|
import config from './config';
|
||||||
import { RequireAuth } from './components/auth/RequireAuth';
|
import { RequireAuth } from './components/auth/RequireAuth';
|
||||||
import Forecasting from "@/pages/Forecasting";
|
import Forecasting from "@/pages/Forecasting";
|
||||||
|
import { Vendors } from '@/pages/Vendors';
|
||||||
|
import { Categories } from '@/pages/Categories';
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -58,6 +60,8 @@ function App() {
|
|||||||
}>
|
}>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/products" element={<Products />} />
|
<Route path="/products" element={<Products />} />
|
||||||
|
<Route path="/categories" element={<Categories />} />
|
||||||
|
<Route path="/vendors" element={<Vendors />} />
|
||||||
<Route path="/orders" element={<Orders />} />
|
<Route path="/orders" element={<Orders />} />
|
||||||
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
<Route path="/purchase-orders" element={<PurchaseOrders />} />
|
||||||
<Route path="/analytics" element={<Analytics />} />
|
<Route path="/analytics" element={<Analytics />} />
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
Users,
|
||||||
|
Tags,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -33,6 +35,16 @@ const items = [
|
|||||||
icon: Package,
|
icon: Package,
|
||||||
url: "/products",
|
url: "/products",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Categories",
|
||||||
|
icon: Tags,
|
||||||
|
url: "/categories",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Vendors",
|
||||||
|
icon: Users,
|
||||||
|
url: "/vendors",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Forecasting",
|
title: "Forecasting",
|
||||||
icon: IconCrystalBall,
|
icon: IconCrystalBall,
|
||||||
|
|||||||
283
inventory/src/pages/Categories.tsx
Normal file
283
inventory/src/pages/Categories.tsx
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
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";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import config from "../config";
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
category_id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
parent_category?: string;
|
||||||
|
product_count: number;
|
||||||
|
total_value: number;
|
||||||
|
avg_margin: number;
|
||||||
|
turnover_rate: number;
|
||||||
|
growth_rate: number;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CategoryFilters {
|
||||||
|
search: string;
|
||||||
|
parent: string;
|
||||||
|
performance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Categories() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortColumn, setSortColumn] = useState<keyof Category>("name");
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [filters, setFilters] = useState<CategoryFilters>({
|
||||||
|
search: "",
|
||||||
|
parent: "all",
|
||||||
|
performance: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["categories", page, sortColumn, sortDirection, filters],
|
||||||
|
queryFn: async () => {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
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");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (column: keyof Category) => {
|
||||||
|
setSortDirection(prev => {
|
||||||
|
if (sortColumn !== column) return "asc";
|
||||||
|
return prev === "asc" ? "desc" : "asc";
|
||||||
|
});
|
||||||
|
setSortColumn(column);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPerformanceBadge = (growth: number) => {
|
||||||
|
if (growth >= 20) return <Badge variant="default">High Growth</Badge>;
|
||||||
|
if (growth >= 5) return <Badge variant="secondary">Growing</Badge>;
|
||||||
|
if (growth >= -5) return <Badge variant="outline">Stable</Badge>;
|
||||||
|
return <Badge variant="destructive">Declining</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatCurrency = (value: number) => {
|
||||||
|
return new Intl.NumberFormat('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
}).format(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div layout className="container mx-auto py-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{data?.pagination.total.toLocaleString() ?? "..."} categories
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="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 Categories</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.stats.totalCategories ?? "..."}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{data?.stats.activeCategories ?? "..."} active
|
||||||
|
</p>
|
||||||
|
</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">{data?.stats.totalValue ? formatCurrency(data.stats.totalValue) : "..."}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Inventory value
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Margin</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.stats.avgMargin?.toFixed(1) ?? "..."}%</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Across all categories
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Growth</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.stats.avgGrowth?.toFixed(1) ?? "..."}%</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Year over year
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search categories..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||||
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filters.parent}
|
||||||
|
onValueChange={(value) => setFilters(prev => ({ ...prev, parent: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[180px]">
|
||||||
|
<SelectValue placeholder="Parent Category" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Categories</SelectItem>
|
||||||
|
<SelectItem value="none">Top Level Only</SelectItem>
|
||||||
|
{data?.parentCategories?.map((parent: string) => (
|
||||||
|
<SelectItem key={parent} value={parent}>{parent}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filters.performance}
|
||||||
|
onValueChange={(value) => setFilters(prev => ({ ...prev, performance: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[150px]">
|
||||||
|
<SelectValue placeholder="Performance" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Performance</SelectItem>
|
||||||
|
<SelectItem value="high_growth">High Growth</SelectItem>
|
||||||
|
<SelectItem value="growing">Growing</SelectItem>
|
||||||
|
<SelectItem value="stable">Stable</SelectItem>
|
||||||
|
<SelectItem value="declining">Declining</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Name</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("parent_category")} className="cursor-pointer">Parent</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("product_count")} className="cursor-pointer">Products</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("total_value")} className="cursor-pointer">Value</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("avg_margin")} className="cursor-pointer">Margin</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("turnover_rate")} className="cursor-pointer">Turnover</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("growth_rate")} className="cursor-pointer">Growth</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("status")} className="cursor-pointer">Status</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
Loading categories...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.categories.map((category: Category) => (
|
||||||
|
<TableRow key={category.category_id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium">{category.name}</div>
|
||||||
|
<div className="text-sm text-muted-foreground">{category.description}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{category.parent_category || "—"}</TableCell>
|
||||||
|
<TableCell>{category.product_count.toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{formatCurrency(category.total_value)}</TableCell>
|
||||||
|
<TableCell>{category.avg_margin.toFixed(1)}%</TableCell>
|
||||||
|
<TableCell>{category.turnover_rate.toFixed(1)}x</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{category.growth_rate.toFixed(1)}%
|
||||||
|
{getPerformanceBadge(category.growth_rate)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{category.status}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!isLoading && !data?.categories.length && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||||
|
No categories found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.pagination.pages > 1 && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(p => Math.max(1, p - 1));
|
||||||
|
}}
|
||||||
|
aria-disabled={page === 1}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<PaginationItem key={p}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive={page === p}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(p => Math.min(data.pagination.pages, p + 1));
|
||||||
|
}}
|
||||||
|
aria-disabled={page === data.pagination.pages}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Categories;
|
||||||
274
inventory/src/pages/Vendors.tsx
Normal file
274
inventory/src/pages/Vendors.tsx
Normal file
@@ -0,0 +1,274 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { useQuery, keepPreviousData } from "@tanstack/react-query";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
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";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import config from "../config";
|
||||||
|
|
||||||
|
interface Vendor {
|
||||||
|
vendor_id: number;
|
||||||
|
name: string;
|
||||||
|
contact_name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
status: string;
|
||||||
|
avg_lead_time_days: number;
|
||||||
|
on_time_delivery_rate: number;
|
||||||
|
order_fill_rate: number;
|
||||||
|
total_orders: number;
|
||||||
|
active_products: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VendorFilters {
|
||||||
|
search: string;
|
||||||
|
status: string;
|
||||||
|
performance: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Vendors() {
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [sortColumn, setSortColumn] = useState<keyof Vendor>("name");
|
||||||
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
|
const [filters, setFilters] = useState<VendorFilters>({
|
||||||
|
search: "",
|
||||||
|
status: "all",
|
||||||
|
performance: "all",
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ["vendors", page, sortColumn, sortDirection, filters],
|
||||||
|
queryFn: async () => {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
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");
|
||||||
|
return response.json();
|
||||||
|
},
|
||||||
|
placeholderData: keepPreviousData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSort = (column: keyof Vendor) => {
|
||||||
|
setSortDirection(prev => {
|
||||||
|
if (sortColumn !== column) return "asc";
|
||||||
|
return prev === "asc" ? "desc" : "asc";
|
||||||
|
});
|
||||||
|
setSortColumn(column);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPerformanceBadge = (fillRate: number) => {
|
||||||
|
if (fillRate >= 95) return <Badge variant="default">Excellent</Badge>;
|
||||||
|
if (fillRate >= 85) return <Badge variant="secondary">Good</Badge>;
|
||||||
|
if (fillRate >= 75) return <Badge variant="outline">Fair</Badge>;
|
||||||
|
return <Badge variant="destructive">Poor</Badge>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div layout className="container mx-auto py-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{data?.pagination.total.toLocaleString() ?? "..."} vendors
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="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 Vendors</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.stats.totalVendors ?? "..."}</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{data?.stats.activeVendors ?? "..."} active
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Avg Lead Time</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.stats.avgLeadTime?.toFixed(1) ?? "..."} days</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Across all vendors
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Fill Rate</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.stats.avgFillRate?.toFixed(1) ?? "..."}%</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Average order fill rate
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">On-Time Delivery</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{data?.stats.avgOnTimeDelivery?.toFixed(1) ?? "..."}%</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Average on-time rate
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex flex-1 items-center space-x-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Search vendors..."
|
||||||
|
value={filters.search}
|
||||||
|
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
||||||
|
className="h-8 w-[150px] lg:w-[250px]"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={filters.status}
|
||||||
|
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[150px]">
|
||||||
|
<SelectValue placeholder="Status" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="inactive">Inactive</SelectItem>
|
||||||
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Select
|
||||||
|
value={filters.performance}
|
||||||
|
onValueChange={(value) => setFilters(prev => ({ ...prev, performance: value }))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[150px]">
|
||||||
|
<SelectValue placeholder="Performance" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">All Performance</SelectItem>
|
||||||
|
<SelectItem value="excellent">Excellent</SelectItem>
|
||||||
|
<SelectItem value="good">Good</SelectItem>
|
||||||
|
<SelectItem value="fair">Fair</SelectItem>
|
||||||
|
<SelectItem value="poor">Poor</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Name</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("contact_name")} className="cursor-pointer">Contact</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("on_time_delivery_rate")} className="cursor-pointer">On-Time Rate</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("order_fill_rate")} className="cursor-pointer">Fill Rate</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("total_orders")} className="cursor-pointer">Orders</TableHead>
|
||||||
|
<TableHead onClick={() => handleSort("active_products")} className="cursor-pointer">Products</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8">
|
||||||
|
Loading vendors...
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : data?.vendors.map((vendor: Vendor) => (
|
||||||
|
<TableRow key={vendor.vendor_id}>
|
||||||
|
<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.avg_lead_time_days.toFixed(1)} days</TableCell>
|
||||||
|
<TableCell>{vendor.on_time_delivery_rate.toFixed(1)}%</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{vendor.order_fill_rate.toFixed(1)}%
|
||||||
|
{getPerformanceBadge(vendor.order_fill_rate)}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{vendor.total_orders.toLocaleString()}</TableCell>
|
||||||
|
<TableCell>{vendor.active_products.toLocaleString()}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{!isLoading && !data?.vendors.length && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||||
|
No vendors found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{data?.pagination.pages > 1 && (
|
||||||
|
<div className="flex justify-center">
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(p => Math.max(1, p - 1));
|
||||||
|
}}
|
||||||
|
aria-disabled={page === 1}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
{Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => (
|
||||||
|
<PaginationItem key={p}>
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
isActive={page === p}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(p);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{p}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
))}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setPage(p => Math.min(data.pagination.pages, p + 1));
|
||||||
|
}}
|
||||||
|
aria-disabled={page === data.pagination.pages}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Vendors;
|
||||||
Reference in New Issue
Block a user