Consolidate old/new vendor and category routes, enhance new brands route, update frontend accordingly for all three pages, improve hierarchy on categories page, fix some calculations
This commit is contained in:
@@ -3,21 +3,23 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { motion } from "framer-motion";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import config from "../config";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
// Matches backend COLUMN_MAP keys for sorting
|
||||
type BrandSortableColumns =
|
||||
| 'brandName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
|
||||
| 'currentStockCost' | 'currentStockRetail' | 'revenue_7d' | 'revenue_30d'
|
||||
| 'profit_30d' | 'sales_30d' | 'avg_margin_30d'; // Add more as needed
|
||||
| 'profit_30d' | 'sales_30d' | 'avg_margin_30d' | 'stock_turn_30d' | 'status'; // Add more as needed
|
||||
|
||||
interface BrandMetric {
|
||||
// Assuming brand_name is unique primary identifier in brand_metrics
|
||||
brand_id: string | number;
|
||||
brand_name: string;
|
||||
last_calculated: string;
|
||||
product_count: number;
|
||||
@@ -37,7 +39,12 @@ interface BrandMetric {
|
||||
lifetime_sales: number;
|
||||
lifetime_revenue: string | number;
|
||||
avg_margin_30d: string | number | null;
|
||||
stock_turn_30d: string | number | null;
|
||||
status: string;
|
||||
brand_status: string;
|
||||
description: string;
|
||||
// Camel case versions
|
||||
brandId: string | number;
|
||||
brandName: string;
|
||||
lastCalculated: string;
|
||||
productCount: number;
|
||||
@@ -49,6 +56,7 @@ interface BrandMetric {
|
||||
lifetimeSales: number;
|
||||
lifetimeRevenue: string | number;
|
||||
avgMargin_30d: string | number | null;
|
||||
stockTurn_30d: string | number | null;
|
||||
}
|
||||
|
||||
// Define response type to avoid type errors
|
||||
@@ -62,11 +70,13 @@ interface BrandResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// Filter options are just a list of names, not useful for dropdowns here
|
||||
// interface BrandFilterOptions { brands: string[]; }
|
||||
interface BrandFilterOptions {
|
||||
statuses: string[];
|
||||
}
|
||||
|
||||
interface BrandStats {
|
||||
totalBrands: number;
|
||||
activeBrands: number;
|
||||
totalActiveProducts: number; // SUM(active_product_count)
|
||||
totalValue: number; // SUM(current_stock_cost)
|
||||
avgMargin: number; // Weighted avg margin 30d
|
||||
@@ -74,7 +84,8 @@ interface BrandStats {
|
||||
|
||||
interface BrandFilters {
|
||||
search: string;
|
||||
showInactive: boolean; // New filter for showing brands with 0 active products
|
||||
status: string;
|
||||
showInactive: boolean; // Show brands with 0 active products
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
@@ -129,6 +140,19 @@ const formatPercentage = (value: number | string | null | undefined, digits = 1)
|
||||
return `${value.toFixed(digits)}%`;
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'default';
|
||||
case 'inactive':
|
||||
return 'secondary';
|
||||
case 'discontinued':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
export function Brands() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(ITEMS_PER_PAGE);
|
||||
@@ -136,6 +160,7 @@ export function Brands() {
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [filters, setFilters] = useState<BrandFilters>({
|
||||
search: "",
|
||||
status: "all",
|
||||
showInactive: false, // Default to hiding brands with 0 active products
|
||||
});
|
||||
|
||||
@@ -151,6 +176,9 @@ export function Brands() {
|
||||
if (filters.search) {
|
||||
params.set('brandName_ilike', filters.search); // Filter by name
|
||||
}
|
||||
if (filters.status !== 'all') {
|
||||
params.set('status', filters.status); // Filter by status
|
||||
}
|
||||
if (!filters.showInactive) {
|
||||
params.set('activeProductCount_gt', '0'); // Only show brands with active products
|
||||
}
|
||||
@@ -184,8 +212,17 @@ export function Brands() {
|
||||
},
|
||||
});
|
||||
|
||||
// Filter options query might not be needed if only search is used
|
||||
// const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<BrandFilterOptions, Error>({ ... });
|
||||
// Fetch filter options
|
||||
const { data: filterOptions } = useQuery<BrandFilterOptions, Error>({
|
||||
queryKey: ['brandsFilterOptions'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/brands-aggregate/filter-options`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch filter options");
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
// --- Event Handlers ---
|
||||
|
||||
@@ -236,7 +273,8 @@ export function Brands() {
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalBrands)}</div>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All brands with metrics
|
||||
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||
`${formatNumber(statsData?.activeBrands)} active`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -276,22 +314,36 @@ export function Brands() {
|
||||
</motion.div>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center justify-between space-x-2">
|
||||
<Input
|
||||
placeholder="Search brands..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-[150px] lg:w-[250px]"
|
||||
<div className="flex flex-wrap items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<Input
|
||||
placeholder="Search brands..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-full sm:w-[250px]"
|
||||
/>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => handleFilterChange('status', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{filterOptions?.statuses?.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2 ml-auto">
|
||||
<Switch
|
||||
id="show-inactive-brands"
|
||||
checked={filters.showInactive}
|
||||
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="show-inactive-brands"
|
||||
checked={filters.showInactive}
|
||||
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
|
||||
/>
|
||||
<Label htmlFor="show-inactive-brands">Show brands with no active products</Label>
|
||||
</div>
|
||||
<Label htmlFor="show-inactive-brands">Show brands with no active products</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -308,6 +360,8 @@ export function Brands() {
|
||||
<TableHead onClick={() => handleSort("revenue_30d")} className="cursor-pointer text-right">Revenue (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("stock_turn_30d")} className="cursor-pointer text-right">Stock Turn (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -322,23 +376,25 @@ export function Brands() {
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : listError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-destructive">
|
||||
<TableCell colSpan={9} className="text-center py-8 text-destructive">
|
||||
Error loading brands: {listError.message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : brands.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
|
||||
No brands found matching your criteria.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
brands.map((brand: BrandMetric) => (
|
||||
<TableRow key={brand.brand_name}> {/* Use brand_name as key */}
|
||||
<TableRow key={brand.brand_id} className={brand.active_product_count === 0 ? "opacity-60" : ""}>
|
||||
<TableCell className="font-medium">{brand.brand_name}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(brand.active_product_count || brand.activeProductCount)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(brand.current_stock_units || brand.currentStockUnits)}</TableCell>
|
||||
@@ -347,6 +403,12 @@ export function Brands() {
|
||||
<TableCell className="text-right">{formatCurrency(brand.revenue_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(brand.profit_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatPercentage(brand.avg_margin_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(brand.stock_turn_30d, 2)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getStatusVariant(brand.status)}>
|
||||
{brand.status || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@ import { useState, useMemo, useCallback } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
// import { Badge } from "@/components/ui/badge"; // Badge removed as status/performance filters are gone
|
||||
// import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; // Select removed as filters are gone
|
||||
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 "framer-motion";
|
||||
import config from "../config";
|
||||
@@ -16,10 +16,10 @@ import { Label } from "@/components/ui/label";
|
||||
type VendorSortableColumns =
|
||||
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
|
||||
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
|
||||
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d';
|
||||
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d' | 'status';
|
||||
|
||||
interface VendorMetric {
|
||||
// Assuming vendor_name is unique primary identifier
|
||||
vendor_id: string | number;
|
||||
vendor_name: string;
|
||||
last_calculated: string;
|
||||
product_count: number;
|
||||
@@ -43,7 +43,16 @@ interface VendorMetric {
|
||||
lifetime_sales: number;
|
||||
lifetime_revenue: string | number;
|
||||
avg_margin_30d: string | number | null;
|
||||
// New fields added by vendorsAggregate
|
||||
status: string;
|
||||
vendor_status: string;
|
||||
cost_metrics_30d: {
|
||||
avg_unit_cost: number;
|
||||
total_spend: number;
|
||||
order_count: number;
|
||||
};
|
||||
// Camel case versions
|
||||
vendorId: string | number;
|
||||
vendorName: string;
|
||||
lastCalculated: string;
|
||||
productCount: number;
|
||||
@@ -72,26 +81,27 @@ interface VendorResponse {
|
||||
};
|
||||
}
|
||||
|
||||
// Filter options are just a list of names, not useful for dropdowns here
|
||||
// interface VendorFilterOptions { vendors: string[]; }
|
||||
interface VendorFilterOptions {
|
||||
statuses: string[];
|
||||
}
|
||||
|
||||
interface VendorStats {
|
||||
totalVendors: number;
|
||||
totalActiveProducts: number; // This seems to be SUM(active_product_count) per vendor
|
||||
totalValue: number; // SUM(current_stock_cost)
|
||||
totalOnOrderValue: number; // SUM(on_order_cost)
|
||||
avgLeadTime: number; // AVG(avg_lead_time_days)
|
||||
activeVendors: number;
|
||||
totalActiveProducts: number;
|
||||
totalValue: number;
|
||||
totalOnOrderValue: number;
|
||||
avgLeadTime: number;
|
||||
}
|
||||
|
||||
interface VendorFilters {
|
||||
search: string;
|
||||
showInactive: boolean; // New filter for showing vendors with 0 active products
|
||||
// Status and Performance filters removed
|
||||
status: string;
|
||||
showInactive: boolean;
|
||||
}
|
||||
|
||||
const ITEMS_PER_PAGE = 50;
|
||||
|
||||
// Re-use formatting helpers from Categories or define here
|
||||
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
|
||||
if (value == null) return 'N/A';
|
||||
if (typeof value === 'string') {
|
||||
@@ -152,6 +162,19 @@ const formatDays = (value: number | string | null | undefined, digits = 1): stri
|
||||
return `${value.toFixed(digits)} days`;
|
||||
};
|
||||
|
||||
const getStatusVariant = (status: string): "default" | "secondary" | "outline" | "destructive" => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return 'default';
|
||||
case 'inactive':
|
||||
return 'secondary';
|
||||
case 'discontinued':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
export function Vendors() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [limit] = useState(ITEMS_PER_PAGE);
|
||||
@@ -159,6 +182,7 @@ export function Vendors() {
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
const [filters, setFilters] = useState<VendorFilters>({
|
||||
search: "",
|
||||
status: "all",
|
||||
showInactive: false, // Default to hiding vendors with 0 active products
|
||||
});
|
||||
|
||||
@@ -174,10 +198,12 @@ export function Vendors() {
|
||||
if (filters.search) {
|
||||
params.set('vendorName_ilike', filters.search); // Filter by name
|
||||
}
|
||||
if (filters.status !== 'all') {
|
||||
params.set('status', filters.status); // Filter by status
|
||||
}
|
||||
if (!filters.showInactive) {
|
||||
params.set('activeProductCount_gt', '0'); // Only show vendors with active products
|
||||
}
|
||||
// Add more filters here if needed (e.g., avgLeadTimeDays_lte=10)
|
||||
|
||||
return params;
|
||||
}, [page, limit, sortColumn, sortDirection, filters]);
|
||||
@@ -189,9 +215,7 @@ export function Vendors() {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
|
||||
const data = await response.json();
|
||||
console.log('Vendors data:', JSON.stringify(data, null, 2));
|
||||
return data;
|
||||
return response.json();
|
||||
},
|
||||
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
|
||||
});
|
||||
@@ -207,8 +231,17 @@ export function Vendors() {
|
||||
},
|
||||
});
|
||||
|
||||
// Filter options query might not be needed if only search is used
|
||||
// const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<VendorFilterOptions, Error>({ ... });
|
||||
// Fetch filter options
|
||||
const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<VendorFilterOptions, Error>({
|
||||
queryKey: ['vendorsFilterOptions'],
|
||||
queryFn: async () => {
|
||||
const response = await fetch(`${config.apiUrl}/vendors-aggregate/filter-options`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
if (!response.ok) throw new Error("Failed to fetch filter options");
|
||||
return response.json();
|
||||
},
|
||||
});
|
||||
|
||||
// --- Event Handlers ---
|
||||
|
||||
@@ -257,10 +290,10 @@ export function Vendors() {
|
||||
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalVendors)}</div>}
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalVendors)}</div>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{/* Active vendor count not directly available, showing total */}
|
||||
All vendors with metrics
|
||||
{isLoadingStats ? <Skeleton className="h-4 w-28" /> :
|
||||
`${formatNumber(statsData?.activeVendors)} active`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -269,9 +302,9 @@ export function Vendors() {
|
||||
<CardTitle className="text-sm font-medium">Total Stock Value</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Current cost value
|
||||
Current cost value
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -281,67 +314,79 @@ export function Vendors() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalOnOrderValue)}</div>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total cost on open POs
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<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>
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatDays(statsData?.avgLeadTime)}</div>}
|
||||
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatDays(statsData?.avgLeadTime)}</div>}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Average across vendors
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* Note: Total Spend and Performance cards removed */}
|
||||
</motion.div>
|
||||
|
||||
{/* Filter Controls */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-1 items-center justify-between space-x-2">
|
||||
<Input
|
||||
placeholder="Search vendors..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-[150px] lg:w-[250px]"
|
||||
<div className="flex flex-wrap items-center space-y-2 sm:space-y-0 sm:space-x-2">
|
||||
<Input
|
||||
placeholder="Search vendors..."
|
||||
value={filters.search}
|
||||
onChange={(e) => handleFilterChange('search', e.target.value)}
|
||||
className="w-full sm:w-[250px]"
|
||||
/>
|
||||
<Select
|
||||
value={filters.status}
|
||||
onValueChange={(value) => handleFilterChange('status', value)}
|
||||
>
|
||||
<SelectTrigger className="w-full sm:w-[180px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
{filterOptions?.statuses?.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex items-center space-x-2 ml-auto">
|
||||
<Switch
|
||||
id="show-inactive-vendors"
|
||||
checked={filters.showInactive}
|
||||
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="show-inactive-vendors"
|
||||
checked={filters.showInactive}
|
||||
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
|
||||
/>
|
||||
<Label htmlFor="show-inactive-vendors">Show vendors with no active products</Label>
|
||||
</div>
|
||||
{/* Note: Status and Performance Select dropdowns removed */}
|
||||
<Label htmlFor="show-inactive-vendors">Show vendors with no active products</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Data Table */}
|
||||
<div className="rounded-md border">
|
||||
{/* Data Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead onClick={() => handleSort("vendorName")} className="cursor-pointer">Vendor</TableHead>
|
||||
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
|
||||
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Value</TableHead>
|
||||
<TableHead onClick={() => handleSort("onOrderUnits")} className="cursor-pointer text-right">On Order (Units)</TableHead>
|
||||
<TableHead onClick={() => handleSort("onOrderCost")} className="cursor-pointer text-right">On Order (Cost)</TableHead>
|
||||
<TableHead onClick={() => handleSort("avgLeadTimeDays")} className="cursor-pointer text-right">Avg Lead Time</TableHead>
|
||||
<TableHead onClick={() => handleSort("revenue_30d")} className="cursor-pointer text-right">Revenue (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead>
|
||||
{/* Removed: Status, On-Time %, Fill Rate, Avg Unit Cost, Total Spend, Orders */}
|
||||
<TableHead onClick={() => handleSort("vendorName")} className="cursor-pointer">Vendor</TableHead>
|
||||
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
|
||||
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Value</TableHead>
|
||||
<TableHead onClick={() => handleSort("onOrderUnits")} className="cursor-pointer text-right">On Order (Units)</TableHead>
|
||||
<TableHead onClick={() => handleSort("onOrderCost")} className="cursor-pointer text-right">On Order (Cost)</TableHead>
|
||||
<TableHead onClick={() => handleSort("avgLeadTimeDays")} className="cursor-pointer text-right">Avg Lead Time</TableHead>
|
||||
<TableHead onClick={() => handleSort("revenue_30d")} className="cursor-pointer text-right">Revenue (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("profit_30d")} className="cursor-pointer text-right">Profit (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("avg_margin_30d")} className="cursor-pointer text-right">Margin (30d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("po_count_365d")} className="cursor-pointer text-right">POs (365d)</TableHead>
|
||||
<TableHead onClick={() => handleSort("status")} className="cursor-pointer text-right">Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{isLoadingList && !listData ? (
|
||||
Array.from({ length: 5 }).map((_, i) => ( // Skeleton rows
|
||||
Array.from({ length: 5 }).map((_, i) => ( // Skeleton rows
|
||||
<TableRow key={`skel-${i}`}>
|
||||
<TableCell><Skeleton className="h-5 w-40" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
@@ -353,23 +398,24 @@ export function Vendors() {
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-20 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
<TableCell className="text-right"><Skeleton className="h-5 w-16 ml-auto" /></TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
))
|
||||
) : listError ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center py-8 text-destructive">
|
||||
<TableCell colSpan={11} className="text-center py-8 text-destructive">
|
||||
Error loading vendors: {listError.message}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : vendors.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
|
||||
<TableCell colSpan={11} className="text-center py-8 text-muted-foreground">
|
||||
No vendors found matching your criteria.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
vendors.map((vendor: VendorMetric) => (
|
||||
<TableRow key={vendor.vendor_name}> {/* Use vendor_name as key assuming it's unique */}
|
||||
<TableRow key={vendor.vendor_id} className={vendor.active_product_count === 0 ? "opacity-60" : ""}>
|
||||
<TableCell className="font-medium">{vendor.vendor_name}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(vendor.active_product_count || vendor.activeProductCount)}</TableCell>
|
||||
<TableCell className="text-right">{formatCurrency(vendor.current_stock_cost as number)}</TableCell>
|
||||
@@ -380,6 +426,11 @@ export function Vendors() {
|
||||
<TableCell className="text-right">{formatCurrency(vendor.profit_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatPercentage(vendor.avg_margin_30d as number)}</TableCell>
|
||||
<TableCell className="text-right">{formatNumber(vendor.po_count_365d || vendor.poCount_365d)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Badge variant={getStatusVariant(vendor.status)}>
|
||||
{vendor.status || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
@@ -387,10 +438,10 @@ export function Vendors() {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination Controls */}
|
||||
{/* Pagination Controls */}
|
||||
{totalPages > 1 && pagination && (
|
||||
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex justify-center">
|
||||
<Pagination>
|
||||
<div className="flex justify-center">
|
||||
<Pagination>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<PaginationPrevious
|
||||
@@ -400,7 +451,7 @@ export function Vendors() {
|
||||
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
{[...Array(totalPages)].map((_, i) => (
|
||||
{[...Array(totalPages)].map((_, i) => (
|
||||
<PaginationItem key={i + 1}>
|
||||
<PaginationLink
|
||||
href="#"
|
||||
@@ -410,18 +461,18 @@ export function Vendors() {
|
||||
{i + 1}
|
||||
</PaginationLink>
|
||||
</PaginationItem>
|
||||
))}
|
||||
))}
|
||||
<PaginationItem>
|
||||
<PaginationNext
|
||||
href="#"
|
||||
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage + 1); }}
|
||||
aria-disabled={pagination.currentPage >= totalPages}
|
||||
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
className={pagination.currentPage >= totalPages ? "pointer-events-none opacity-50" : ""}
|
||||
/>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user