From 1ea02b8938e5933a8b987d30d59331ea17a79296 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 15 Jan 2025 23:59:43 -0500 Subject: [PATCH] Add frontend changes --- inventory/src/App.tsx | 4 + .../src/components/layout/AppSidebar.tsx | 12 + inventory/src/pages/Categories.tsx | 283 ++++++++++++++++++ inventory/src/pages/Vendors.tsx | 274 +++++++++++++++++ 4 files changed, 573 insertions(+) create mode 100644 inventory/src/pages/Categories.tsx create mode 100644 inventory/src/pages/Vendors.tsx diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index b0b5324..898750f 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -13,6 +13,8 @@ import { useEffect } from 'react'; import config from './config'; import { RequireAuth } from './components/auth/RequireAuth'; import Forecasting from "@/pages/Forecasting"; +import { Vendors } from '@/pages/Vendors'; +import { Categories } from '@/pages/Categories'; const queryClient = new QueryClient(); @@ -58,6 +60,8 @@ function App() { }> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 3464a15..9e5e471 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -6,6 +6,8 @@ import { Box, ClipboardList, LogOut, + Users, + Tags, } from "lucide-react"; import { IconCrystalBall } from "@tabler/icons-react"; import { @@ -33,6 +35,16 @@ const items = [ icon: Package, url: "/products", }, + { + title: "Categories", + icon: Tags, + url: "/categories", + }, + { + title: "Vendors", + icon: Users, + url: "/vendors", + }, { title: "Forecasting", icon: IconCrystalBall, diff --git a/inventory/src/pages/Categories.tsx b/inventory/src/pages/Categories.tsx new file mode 100644 index 0000000..c440b1d --- /dev/null +++ b/inventory/src/pages/Categories.tsx @@ -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("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [filters, setFilters] = useState({ + 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 High Growth; + if (growth >= 5) return Growing; + if (growth >= -5) return Stable; + return Declining; + }; + + const formatCurrency = (value: number) => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value); + }; + + return ( + +
+

Categories

+
+ {data?.pagination.total.toLocaleString() ?? "..."} categories +
+
+ +
+ + + Total Categories + + +
{data?.stats.totalCategories ?? "..."}
+

+ {data?.stats.activeCategories ?? "..."} active +

+
+
+ + + + Total Value + + +
{data?.stats.totalValue ? formatCurrency(data.stats.totalValue) : "..."}
+

+ Inventory value +

+
+
+ + + + Avg Margin + + +
{data?.stats.avgMargin?.toFixed(1) ?? "..."}%
+

+ Across all categories +

+
+
+ + + + Avg Growth + + +
{data?.stats.avgGrowth?.toFixed(1) ?? "..."}%
+

+ Year over year +

+
+
+
+ +
+
+ setFilters(prev => ({ ...prev, search: e.target.value }))} + className="h-8 w-[150px] lg:w-[250px]" + /> + + +
+
+ +
+ + + + handleSort("name")} className="cursor-pointer">Name + handleSort("parent_category")} className="cursor-pointer">Parent + handleSort("product_count")} className="cursor-pointer">Products + handleSort("total_value")} className="cursor-pointer">Value + handleSort("avg_margin")} className="cursor-pointer">Margin + handleSort("turnover_rate")} className="cursor-pointer">Turnover + handleSort("growth_rate")} className="cursor-pointer">Growth + handleSort("status")} className="cursor-pointer">Status + + + + {isLoading ? ( + + + Loading categories... + + + ) : data?.categories.map((category: Category) => ( + + +
{category.name}
+
{category.description}
+
+ {category.parent_category || "—"} + {category.product_count.toLocaleString()} + {formatCurrency(category.total_value)} + {category.avg_margin.toFixed(1)}% + {category.turnover_rate.toFixed(1)}x + +
+ {category.growth_rate.toFixed(1)}% + {getPerformanceBadge(category.growth_rate)} +
+
+ {category.status} +
+ ))} + {!isLoading && !data?.categories.length && ( + + + No categories found + + + )} +
+
+
+ + {data?.pagination.pages > 1 && ( +
+ + + + { + e.preventDefault(); + setPage(p => Math.max(1, p - 1)); + }} + aria-disabled={page === 1} + /> + + {Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => ( + + { + e.preventDefault(); + setPage(p); + }} + > + {p} + + + ))} + + { + e.preventDefault(); + setPage(p => Math.min(data.pagination.pages, p + 1)); + }} + aria-disabled={page === data.pagination.pages} + /> + + + +
+ )} +
+ ); +} + +export default Categories; \ No newline at end of file diff --git a/inventory/src/pages/Vendors.tsx b/inventory/src/pages/Vendors.tsx new file mode 100644 index 0000000..3167159 --- /dev/null +++ b/inventory/src/pages/Vendors.tsx @@ -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("name"); + const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc"); + const [filters, setFilters] = useState({ + 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 Excellent; + if (fillRate >= 85) return Good; + if (fillRate >= 75) return Fair; + return Poor; + }; + + return ( + +
+

Vendors

+
+ {data?.pagination.total.toLocaleString() ?? "..."} vendors +
+
+ +
+ + + Total Vendors + + +
{data?.stats.totalVendors ?? "..."}
+

+ {data?.stats.activeVendors ?? "..."} active +

+
+
+ + + + Avg Lead Time + + +
{data?.stats.avgLeadTime?.toFixed(1) ?? "..."} days
+

+ Across all vendors +

+
+
+ + + + Fill Rate + + +
{data?.stats.avgFillRate?.toFixed(1) ?? "..."}%
+

+ Average order fill rate +

+
+
+ + + + On-Time Delivery + + +
{data?.stats.avgOnTimeDelivery?.toFixed(1) ?? "..."}%
+

+ Average on-time rate +

+
+
+
+ +
+
+ setFilters(prev => ({ ...prev, search: e.target.value }))} + className="h-8 w-[150px] lg:w-[250px]" + /> + + +
+
+ +
+ + + + handleSort("name")} className="cursor-pointer">Name + handleSort("contact_name")} className="cursor-pointer">Contact + handleSort("status")} className="cursor-pointer">Status + handleSort("avg_lead_time_days")} className="cursor-pointer">Lead Time + handleSort("on_time_delivery_rate")} className="cursor-pointer">On-Time Rate + handleSort("order_fill_rate")} className="cursor-pointer">Fill Rate + handleSort("total_orders")} className="cursor-pointer">Orders + handleSort("active_products")} className="cursor-pointer">Products + + + + {isLoading ? ( + + + Loading vendors... + + + ) : data?.vendors.map((vendor: Vendor) => ( + + {vendor.name} + +
{vendor.contact_name}
+
{vendor.email}
+
+ {vendor.status} + {vendor.avg_lead_time_days.toFixed(1)} days + {vendor.on_time_delivery_rate.toFixed(1)}% + +
+ {vendor.order_fill_rate.toFixed(1)}% + {getPerformanceBadge(vendor.order_fill_rate)} +
+
+ {vendor.total_orders.toLocaleString()} + {vendor.active_products.toLocaleString()} +
+ ))} + {!isLoading && !data?.vendors.length && ( + + + No vendors found + + + )} +
+
+
+ + {data?.pagination.pages > 1 && ( +
+ + + + { + e.preventDefault(); + setPage(p => Math.max(1, p - 1)); + }} + aria-disabled={page === 1} + /> + + {Array.from({ length: data.pagination.pages }, (_, i) => i + 1).map((p) => ( + + { + e.preventDefault(); + setPage(p); + }} + > + {p} + + + ))} + + { + e.preventDefault(); + setPage(p => Math.min(data.pagination.pages, p + 1)); + }} + aria-disabled={page === data.pagination.pages} + /> + + + +
+ )} +
+ ); +} + +export default Vendors; \ No newline at end of file