Update/add frontend pages for categories, brands, vendors new routes, update products page to use new route

This commit is contained in:
2025-04-01 14:34:57 -04:00
parent 1b9f01d101
commit dbd0232285
18 changed files with 2844 additions and 2619 deletions

View File

@@ -192,7 +192,28 @@ router.get('/', async (req, res) => {
]);
const total = parseInt(countResult.rows[0].total, 10);
const brands = dataResult.rows;
const brands = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({

View File

@@ -222,17 +222,20 @@ router.get('/', async (req, res) => {
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
// Need JOIN for parent_name if sorting/filtering by it, or always include for display
const needParentJoin = sortColumn === 'p.name' || conditions.some(c => c.includes('p.name'));
const sortColumn = sortColumnInfo?.dbCol;
// Always include the parent join for consistency
const parentJoinSql = 'LEFT JOIN public.categories p ON cm.parent_id = p.cat_id';
const baseSql = `
FROM public.category_metrics cm
${needParentJoin ? 'LEFT JOIN public.categories p ON cm.parent_id = p.cat_id' : ''}
${parentJoinSql}
${whereClause}
`;
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
const dataSql = `
SELECT cm.* ${needParentJoin ? ', p.name as parent_name' : ''}
SELECT cm.*, p.name as parent_name
${baseSql}
${sortClause}
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
@@ -248,7 +251,28 @@ router.get('/', async (req, res) => {
]);
const total = parseInt(countResult.rows[0].total, 10);
const categories = dataResult.rows;
const categories = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({

View File

@@ -196,7 +196,28 @@ router.get('/', async (req, res) => {
]);
const total = parseInt(countResult.rows[0].total, 10);
const vendors = dataResult.rows;
const vendors = dataResult.rows.map(row => {
// Create a new object with both snake_case and camelCase keys
const transformedRow = { ...row }; // Start with original data
for (const key in row) {
// Skip null/undefined values
if (row[key] === null || row[key] === undefined) {
continue; // Original already has the null value
}
// Transform keys to match frontend expectations (add camelCase versions)
// First handle cases like sales_7d -> sales7d
let camelKey = key.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
if (camelKey !== key) { // Only add if different from original
transformedRow[camelKey] = row[key];
}
}
return transformedRow;
});
// --- Respond ---
res.json({

View File

@@ -61,6 +61,7 @@
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-debounce-input": "^3.3.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",
@@ -6043,6 +6044,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -6919,6 +6926,19 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/react-debounce-input": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/react-debounce-input/-/react-debounce-input-3.3.0.tgz",
"integrity": "sha512-VEqkvs8JvY/IIZvh71Z0TC+mdbxERvYF33RcebnodlsUZ8RSgyKe2VWaHXv4+/8aoOgXLxWrdsYs2hDhcwbUgA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"react": "^15.3.0 || 16 || 17 || 18"
}
},
"node_modules/react-dom": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",

View File

@@ -63,6 +63,7 @@
"react-chartjs-2": "^5.3.0",
"react-data-grid": "^7.0.0-beta.13",
"react-day-picker": "^8.10.1",
"react-debounce-input": "^3.3.0",
"react-dom": "^18.3.1",
"react-dropzone": "^14.3.5",
"react-hook-form": "^7.54.2",

View File

@@ -18,7 +18,7 @@ import { Import } from '@/pages/Import';
import { AuthProvider } from './contexts/AuthContext';
import { Protected } from './components/auth/Protected';
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
import { Brands } from '@/pages/Brands';
const queryClient = new QueryClient();
function App() {
@@ -108,6 +108,11 @@ function App() {
<Vendors />
</Protected>
} />
<Route path="/brands" element={
<Protected page="brands">
<Brands />
</Protected>
} />
<Route path="/purchase-orders" element={
<Protected page="purchase_orders">
<PurchaseOrders />

View File

@@ -0,0 +1,7 @@
const config = {
// API base URL - update based on your actual API endpoint
apiUrl: '/api',
// Add other config values as needed
};
export default config;

View File

@@ -5,9 +5,10 @@ import {
Settings,
ClipboardList,
LogOut,
Users,
Tags,
FileSpreadsheet,
ShoppingBag,
Truck,
} from "lucide-react";
import { IconCrystalBall } from "@tabler/icons-react";
import {
@@ -57,9 +58,15 @@ const items = [
url: "/categories",
permission: "access:categories"
},
{
title: "Brands",
icon: ShoppingBag,
url: "/brands",
permission: "access:brands"
},
{
title: "Vendors",
icon: Users,
icon: Truck,
url: "/vendors",
permission: "access:vendors"
},

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,5 @@
import * as React from "react";
import { SortAsc, SortDesc } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import {
Table,
TableBody,
@@ -14,10 +13,11 @@ import {
DndContext,
DragEndEvent,
DragStartEvent,
MouseSensor,
PointerSensor,
TouchSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import {
SortableContext,
@@ -26,36 +26,38 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Product } from "@/types/products";
export type ColumnKey = keyof Product | 'image';
import { ProductMetric, ProductMetricColumnKey } from "@/types/products";
import { Skeleton } from "@/components/ui/skeleton";
import { getStatusBadge } from "@/utils/productUtils";
// Column definition
interface ColumnDef {
key: ColumnKey;
key: ProductMetricColumnKey;
label: string;
group: string;
format?: (value: any) => string | number;
width?: string;
noLabel?: boolean;
width?: string;
format?: (value: any, product?: ProductMetric) => React.ReactNode;
}
interface ProductTableProps {
products: Product[];
onSort: (column: ColumnKey) => void;
sortColumn: ColumnKey;
products: ProductMetric[];
onSort: (column: ProductMetricColumnKey) => void;
sortColumn: ProductMetricColumnKey;
sortDirection: 'asc' | 'desc';
visibleColumns: Set<ColumnKey>;
visibleColumns: Set<ProductMetricColumnKey>;
columnDefs: ColumnDef[];
columnOrder: ColumnKey[];
onColumnOrderChange?: (columns: ColumnKey[]) => void;
onRowClick?: (product: Product) => void;
columnOrder: ProductMetricColumnKey[];
onColumnOrderChange?: (columns: ProductMetricColumnKey[]) => void;
onRowClick?: (product: ProductMetric) => void;
isLoading?: boolean;
}
interface SortableHeaderProps {
column: ColumnKey;
column: ProductMetricColumnKey;
columnDef?: ColumnDef;
onSort: (column: ColumnKey) => void;
sortColumn: ColumnKey;
onSort: (column: ProductMetricColumnKey) => void;
sortColumn: ProductMetricColumnKey;
sortDirection: 'asc' | 'desc';
}
@@ -73,10 +75,22 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : 1,
position: 'relative' as const,
touchAction: 'none' as const,
};
// Skip rendering content for 'noLabel' columns (like image)
if (columnDef?.noLabel) {
return <TableHead ref={setNodeRef} style={style} />;
return (
<TableHead
ref={setNodeRef}
style={{...style, width: columnDef?.width || 'auto', padding: '0.5rem' }}
className={cn(columnDef?.width, "select-none")}
{...attributes}
{...listeners}
/>
);
}
return (
@@ -84,7 +98,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
ref={setNodeRef}
style={style}
className={cn(
"cursor-pointer select-none",
"cursor-pointer select-none group",
columnDef?.width,
sortColumn === column && "bg-accent/50"
)}
@@ -114,196 +128,103 @@ export function ProductTable({
columnOrder = columnDefs.map(col => col.key),
onColumnOrderChange,
onRowClick,
isLoading = false,
}: ProductTableProps) {
const [, setActiveId] = React.useState<ColumnKey | null>(null);
const [activeId, setActiveId] = React.useState<ProductMetricColumnKey | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 8,
},
useSensor(PointerSensor, {
activationConstraint: { distance: 5 },
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 200,
tolerance: 8,
},
activationConstraint: { delay: 250, tolerance: 5 },
})
);
// Get ordered visible columns
const orderedColumns = React.useMemo(() => {
// Filter columnOrder to only include visible columns for SortableContext
const orderedVisibleColumns = React.useMemo(() => {
return columnOrder.filter(col => visibleColumns.has(col));
}, [columnOrder, visibleColumns]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as ColumnKey);
setActiveId(event.active.id as ProductMetricColumnKey);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (over && active.id !== over.id) {
const oldIndex = orderedColumns.indexOf(active.id as ColumnKey);
const newIndex = orderedColumns.indexOf(over.id as ColumnKey);
if (over && active.id !== over.id && onColumnOrderChange) {
const oldIndex = orderedVisibleColumns.indexOf(active.id as ProductMetricColumnKey);
const newIndex = orderedVisibleColumns.indexOf(over.id as ProductMetricColumnKey);
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
onColumnOrderChange?.(newOrder);
if (oldIndex !== -1 && newIndex !== -1) {
const newVisibleOrder = arrayMove(orderedVisibleColumns, oldIndex, newIndex);
onColumnOrderChange(newVisibleOrder);
}
}
};
const formatColumnValue = (product: ProductMetric, columnKey: ProductMetricColumnKey) => {
const columnDef = columnDefs.find(def => def.key === columnKey);
const value = product[columnKey as keyof ProductMetric];
const getStockStatus = (status: string | undefined) => {
if (!status) return null;
const normalizedStatus = status.toLowerCase().replace(/-/g, ' ');
switch (normalizedStatus) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'reorder':
return <Badge variant="secondary">Reorder</Badge>;
case 'healthy':
return <Badge variant="default">Healthy</Badge>;
case 'overstocked':
return <Badge variant="secondary">Overstocked</Badge>;
case 'new':
return <Badge variant="default">New</Badge>;
case 'out of stock':
return <Badge variant="destructive">Out of Stock</Badge>;
case 'at risk':
return <Badge variant="secondary">At Risk</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
if (columnKey === 'status') {
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status || 'Unknown') }} />;
}
};
const getABCClass = (abcClass: string | undefined) => {
if (!abcClass) return null;
switch (abcClass.toUpperCase()) {
case 'A':
return <Badge variant="default">A</Badge>;
case 'B':
return <Badge variant="secondary">B</Badge>;
case 'C':
return <Badge variant="outline">C</Badge>;
default:
return null;
if (columnDef?.format) {
return columnDef.format(value, product);
}
};
const getLeadTimeStatus = (status: string | undefined) => {
if (!status) return null;
switch (status.toLowerCase()) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'warning':
return <Badge variant="secondary">Warning</Badge>;
case 'good':
return <Badge variant="default">Good</Badge>;
default:
return null;
// Default formatting for common types if no formatter provided
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
};
const formatColumnValue = (product: Product, column: ColumnKey) => {
const columnDef = columnDefs.find(def => def.key === column);
let value: any = product[column as keyof Product];
switch (column) {
case 'image':
return product.image ? (
<div className="flex items-center justify-center w-[60px]">
<img
src={product.image}
alt={product.title}
className="h-12 w-12 object-contain bg-white"
/>
</div>
) : null;
case 'title':
return (
<div className="min-w-[200px]">
<div className="font-medium">{product.title}</div>
<div className="text-sm text-muted-foreground">{product.SKU}</div>
</div>
);
case 'categories':
return (
<div className="flex flex-wrap gap-1">
{Array.from(new Set(value as string[])).map((category) => (
<Badge key={`${product.pid}-${category}`} variant="outline">{category}</Badge>
)) || '-'}
</div>
);
case 'dimensions':
if (value) {
return `${value.length}×${value.width}×${value.height}`;
}
return '-';
case 'stock_status':
return getStockStatus(product.stock_status);
case 'abc_class':
return getABCClass(product.abc_class);
case 'lead_time_status':
return getLeadTimeStatus(product.lead_time_status);
case 'visible':
return value ? (
<Badge variant="secondary">Active</Badge>
) : (
<Badge variant="outline">Hidden</Badge>
);
case 'replenishable':
return value ? (
<Badge variant="secondary">Replenishable</Badge>
) : (
<Badge variant="outline">Non-Replenishable</Badge>
);
case 'rating':
if (value === undefined || value === null) return '-';
return (
<div className="flex items-center">
{value.toFixed(1)}
<span className="ml-1 text-yellow-500"></span>
</div>
);
default:
if (columnDef?.format && value !== undefined && value !== null) {
// For numeric formats (those using toFixed), ensure the value is a number
if (typeof value === 'string') {
const num = parseFloat(value);
if (!isNaN(num)) {
return columnDef.format(num);
}
}
// If the value is already a number, format it directly
if (typeof value === 'number') {
return columnDef.format(value);
}
// For other formats (e.g., date formatting), pass the value as is
return columnDef.format(value);
}
return value ?? '-';
// Handle date strings consistently
if (value && typeof value === 'string' &&
(columnKey.toLowerCase().includes('date') || columnKey === 'replenishDate')) {
try {
return new Date(value).toLocaleDateString();
} catch (e) {
return String(value);
}
}
if (value === null || value === undefined || value === '') {
return '-';
}
// Fallback to string conversion
return String(value);
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => setActiveId(null)}
>
<div className="rounded-md border">
<Table>
<TableHeader>
<div className="rounded-md border overflow-x-auto relative">
{isLoading && (
<div className="absolute inset-0 bg-background/70 flex items-center justify-center z-20">
<Skeleton className="h-8 w-32" />
</div>
)}
<Table className={isLoading ? 'opacity-50' : ''}>
<TableHeader className="sticky top-0 bg-background z-10">
<TableRow>
<SortableContext
items={orderedColumns}
items={orderedVisibleColumns}
strategy={horizontalListSortingStrategy}
>
{orderedColumns.map((column) => (
{orderedVisibleColumns.map((columnKey) => (
<SortableHeader
key={column}
column={column}
columnDef={columnDefs.find(def => def.key === column)}
key={columnKey}
column={columnKey}
columnDef={columnDefs.find(def => def.key === columnKey)}
onSort={onSort}
sortColumn={sortColumn}
sortDirection={sortDirection}
@@ -313,29 +234,55 @@ export function ProductTable({
</TableRow>
</TableHeader>
<TableBody>
{products.map((product) => (
<TableRow
key={product.pid}
onClick={() => onRowClick?.(product)}
className="cursor-pointer"
>
{orderedColumns.map((column) => (
<TableCell key={`${product.pid}-${column}`}>
{formatColumnValue(product, column)}
{products.length === 0 && !isLoading ? (
<TableRow>
<TableCell
colSpan={orderedVisibleColumns.length}
className="text-center py-8 text-muted-foreground"
>
No products found matching your criteria.
</TableCell>
</TableRow>
) : (
products.map((product) => (
<TableRow
key={product.pid}
onClick={() => onRowClick?.(product)}
className="cursor-pointer hover:bg-muted/50"
data-state={isLoading ? 'loading' : undefined}
>
{orderedVisibleColumns.map((columnKey) => (
<TableCell key={`${product.pid}-${columnKey}`} className={cn(columnDefs.find(c=>c.key===columnKey)?.width)}>
{columnKey === 'imageUrl' ? (
<div className="flex items-center justify-center h-12 w-[60px]">
{product.imageUrl ? (
<img
src={product.imageUrl}
alt={product.title || 'Product image'}
className="max-h-full max-w-full object-contain bg-white p-0.5 border rounded"
loading="lazy"
/>
) : (
<div className="h-10 w-10 bg-muted rounded flex items-center justify-center text-muted-foreground text-xs">No Image</div>
)}
</div>
) : (
formatColumnValue(product, columnKey)
)}
</TableCell>
))}
</TableRow>
))
)}
{isLoading && products.length === 0 && Array.from({length: 10}).map((_, i) => (
<TableRow key={`skel-${i}`}>
{orderedVisibleColumns.map(key => (
<TableCell key={`skel-${i}-${key}`} className={cn(columnDefs.find(c=>c.key===key)?.width)}>
<Skeleton className={`h-5 ${key==='imageUrl' ? 'w-10 h-10' : 'w-full'}`} />
</TableCell>
))}
</TableRow>
))}
{!products.length && (
<TableRow>
<TableCell
colSpan={orderedColumns.length}
className="text-center py-8 text-muted-foreground"
>
No products found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>

View File

@@ -0,0 +1,239 @@
import * as React from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { Spinner } from "@/components/ui/spinner";
import { ProductFilterOptions, ProductMetric } from "@/types/products";
import { ProductTable } from "./ProductTable";
import { ProductFilters } from "./ProductFilters";
import { ProductDetail } from "./ProductDetail";
import config from "@/config";
import { getProductStatus } from "@/utils/productUtils";
export function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const [selectedProductId, setSelectedProductId] = React.useState<number | null>(null);
// Get current filter values from URL params
const currentPage = Number(searchParams.get("page") || "1");
const pageSize = Number(searchParams.get("pageSize") || "25");
const sortBy = searchParams.get("sortBy") || "title";
const sortDirection = searchParams.get("sortDirection") || "asc";
const filterType = searchParams.get("filterType") || "";
const filterValue = searchParams.get("filterValue") || "";
const searchQuery = searchParams.get("search") || "";
const statusFilter = searchParams.get("status") || "";
// Fetch filter options
const {
data: filterOptions,
isLoading: isLoadingOptions
} = useQuery<ProductFilterOptions>({
queryKey: ["productFilterOptions"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/metrics/filter-options`, {
credentials: 'include',
});
if (!response.ok) {
return { vendors: [], brands: [], abcClasses: [] };
}
return await response.json();
},
initialData: { vendors: [], brands: [], abcClasses: [] }, // Provide initial data to prevent undefined
});
// Fetch products with metrics data
const {
data,
isLoading,
error
} = useQuery<{ products: ProductMetric[], total: number }>({
queryKey: ["products", currentPage, pageSize, sortBy, sortDirection, filterType, filterValue, searchQuery, statusFilter],
queryFn: async () => {
// Build query parameters
const params = new URLSearchParams();
params.append("page", currentPage.toString());
params.append("limit", pageSize.toString());
if (sortBy) params.append("sortBy", sortBy);
if (sortDirection) params.append("sortDirection", sortDirection);
if (filterType && filterValue) {
params.append("filterType", filterType);
params.append("filterValue", filterValue);
}
if (searchQuery) params.append("search", searchQuery);
if (statusFilter) params.append("status", statusFilter);
const response = await fetch(`${config.apiUrl}/metrics?${params.toString()}`, {
credentials: 'include',
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || `Failed to fetch products (${response.status})`);
}
const data = await response.json();
// Calculate status for each product
const productsWithStatus = data.products.map((product: ProductMetric) => ({
...product,
status: getProductStatus(product)
}));
return {
products: productsWithStatus,
total: data.total
};
},
});
const handlePageChange = (page: number) => {
searchParams.set("page", page.toString());
setSearchParams(searchParams);
};
const handlePageSizeChange = (size: number) => {
searchParams.set("pageSize", size.toString());
searchParams.set("page", "1"); // Reset to first page when changing page size
setSearchParams(searchParams);
};
const handleSortChange = (field: string, direction: "asc" | "desc") => {
searchParams.set("sortBy", field);
searchParams.set("sortDirection", direction);
setSearchParams(searchParams);
};
const handleFilterChange = (type: string, value: string) => {
if (type && value) {
searchParams.set("filterType", type);
searchParams.set("filterValue", value);
} else {
searchParams.delete("filterType");
searchParams.delete("filterValue");
}
searchParams.set("page", "1"); // Reset to first page when applying filters
setSearchParams(searchParams);
};
const handleStatusFilterChange = (status: string) => {
if (status) {
searchParams.set("status", status);
} else {
searchParams.delete("status");
}
searchParams.set("page", "1"); // Reset to first page when changing status filter
setSearchParams(searchParams);
};
const handleSearchChange = (query: string) => {
if (query) {
searchParams.set("search", query);
} else {
searchParams.delete("search");
}
searchParams.set("page", "1"); // Reset to first page when searching
setSearchParams(searchParams);
};
const handleViewProduct = (id: number) => {
setSelectedProductId(id);
};
const handleCloseProductDetail = () => {
setSelectedProductId(null);
};
// Create a wrapper function to handle all filter changes
const handleFiltersChange = (filters: Record<string, any>) => {
// Reset to first page when applying filters
searchParams.set("page", "1");
// Update searchParams with all filters
Object.entries(filters).forEach(([key, value]) => {
if (value) {
searchParams.set(key, String(value));
} else {
searchParams.delete(key);
}
});
setSearchParams(searchParams);
};
// Clear all filters
const handleClearFilters = () => {
// Keep only pagination and sorting params
const newParams = new URLSearchParams();
newParams.set("page", "1");
newParams.set("pageSize", pageSize.toString());
newParams.set("sortBy", sortBy);
newParams.set("sortDirection", sortDirection);
setSearchParams(newParams);
};
// Current active filters
const activeFilters = React.useMemo(() => {
const filters: Record<string, any> = {};
if (filterType && filterValue) {
filters[filterType] = filterValue;
}
if (searchQuery) {
filters.search = searchQuery;
}
if (statusFilter) {
filters.status = statusFilter;
}
return filters;
}, [filterType, filterValue, searchQuery, statusFilter]);
return (
<div className="flex-1 space-y-4 p-4 md:p-8 pt-6">
<div className="flex items-center justify-between">
<h2 className="text-3xl font-bold tracking-tight">Products</h2>
</div>
<ProductFilters
filterOptions={filterOptions || { vendors: [], brands: [], abcClasses: [] }}
isLoadingOptions={isLoadingOptions}
onFilterChange={handleFiltersChange}
onClearFilters={handleClearFilters}
activeFilters={activeFilters}
/>
{isLoading ? (
<div className="flex justify-center items-center min-h-[300px]">
<Spinner size="lg" />
</div>
) : error ? (
<div className="bg-destructive/10 p-4 rounded-lg text-center text-destructive border border-destructive">
Error loading products: {(error as Error).message}
</div>
) : (
<ProductTable
products={data?.products || []}
total={data?.total || 0}
currentPage={currentPage}
pageSize={pageSize}
sortBy={sortBy}
sortDirection={sortDirection as "asc" | "desc"}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
onSortChange={handleSortChange}
onViewProduct={handleViewProduct}
/>
)}
<ProductDetail
productId={selectedProductId}
onClose={handleCloseProductDetail}
/>
</div>
);
}

View File

@@ -0,0 +1,397 @@
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 { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
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";
// 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
interface BrandMetric {
// Assuming brand_name is unique primary identifier in brand_metrics
brand_name: string;
last_calculated: string;
product_count: number;
active_product_count: number;
replenishable_product_count: number;
current_stock_units: number;
current_stock_cost: string | number;
current_stock_retail: string | number;
sales_7d: number;
revenue_7d: string | number;
sales_30d: number;
revenue_30d: string | number;
profit_30d: string | number;
cogs_30d: string | number;
sales_365d: number;
revenue_365d: string | number;
lifetime_sales: number;
lifetime_revenue: string | number;
avg_margin_30d: string | number | null;
// Camel case versions
brandName: string;
lastCalculated: string;
productCount: number;
activeProductCount: number;
replenishableProductCount: number;
currentStockUnits: number;
currentStockCost: string | number;
currentStockRetail: string | number;
lifetimeSales: number;
lifetimeRevenue: string | number;
avgMargin_30d: string | number | null;
}
// Define response type to avoid type errors
interface BrandResponse {
brands: BrandMetric[];
pagination: {
total: number;
pages: number;
currentPage: number;
limit: number;
};
}
// Filter options are just a list of names, not useful for dropdowns here
// interface BrandFilterOptions { brands: string[]; }
interface BrandStats {
totalBrands: number;
totalActiveProducts: number; // SUM(active_product_count)
totalValue: number; // SUM(current_stock_cost)
avgMargin: number; // Weighted avg margin 30d
}
interface BrandFilters {
search: string;
showInactive: boolean; // New filter for showing brands with 0 active products
}
const ITEMS_PER_PAGE = 50;
// Re-use formatting helpers or define here
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(parsed);
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return parsed.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)}%`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)}%`;
};
export function Brands() {
const [page, setPage] = useState(1);
const [limit] = useState(ITEMS_PER_PAGE);
const [sortColumn, setSortColumn] = useState<BrandSortableColumns>("brandName");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<BrandFilters>({
search: "",
showInactive: false, // Default to hiding brands with 0 active products
});
// --- Data Fetching ---
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('limit', limit.toString());
params.set('sort', sortColumn);
params.set('order', sortDirection);
if (filters.search) {
params.set('brandName_ilike', filters.search); // Filter by name
}
if (!filters.showInactive) {
params.set('activeProductCount_gt', '0'); // Only show brands with active products
}
// Add more filters here if needed (e.g., revenue30d_gt=5000)
return params;
}, [page, limit, sortColumn, sortDirection, filters]);
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<BrandResponse, Error>({
queryKey: ['brands', queryParams.toString()],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/brands-aggregate?${queryParams.toString()}`, {
credentials: 'include'
});
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
const data = await response.json();
console.log('Brands data:', JSON.stringify(data, null, 2));
return data;
},
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
});
const { data: statsData, isLoading: isLoadingStats } = useQuery<BrandStats, Error>({
queryKey: ['brandsStats'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/brands-aggregate/stats`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch brand stats");
return response.json();
},
});
// Filter options query might not be needed if only search is used
// const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<BrandFilterOptions, Error>({ ... });
// --- Event Handlers ---
const handleSort = useCallback((column: BrandSortableColumns) => {
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
setSortColumn(column);
setPage(1);
}, [sortColumn]);
const handleFilterChange = useCallback((filterName: keyof BrandFilters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [filterName]: value }));
setPage(1);
}, []);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
setPage(newPage);
}
};
// --- Derived Data ---
const brands = listData?.brands ?? [];
const pagination = listData?.pagination;
const totalPages = pagination?.pages ?? 0;
// --- Rendering ---
return (
<motion.div
layout
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
className="container mx-auto py-6 space-y-4"
>
{/* Header */}
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Brands</h1>
<div className="text-sm text-muted-foreground">
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} brands`}
</div>
</motion.div>
{/* Stats Cards */}
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} 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 Brands</CardTitle>
</CardHeader>
<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
</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 Stock Value</CardTitle>
</CardHeader>
<CardContent>
{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
</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 (30d)</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatPercentage(statsData?.avgMargin)}</div>}
<p className="text-xs text-muted-foreground">
Weighted by revenue
</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 Active Products</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalActiveProducts)}</div>}
<p className="text-xs text-muted-foreground">
Across all brands
</p>
</CardContent>
</Card>
</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 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>
</div>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead onClick={() => handleSort("brandName")} className="cursor-pointer">Brand</TableHead>
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
<TableHead onClick={() => handleSort("currentStockUnits")} className="cursor-pointer text-right">Stock Units</TableHead>
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Cost</TableHead>
<TableHead onClick={() => handleSort("currentStockRetail")} className="cursor-pointer text-right">Stock Retail</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>
</TableRow>
</TableHeader>
<TableBody>
{isLoadingList && !listData ? (
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>
<TableCell className="text-right"><Skeleton className="h-5 w-16 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-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-20 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">
Error loading brands: {listError.message}
</TableCell>
</TableRow>
) : brands.length === 0 ? (
<TableRow>
<TableCell colSpan={8} 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 */}
<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>
<TableCell className="text-right">{formatCurrency(brand.current_stock_cost as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(brand.current_stock_retail as number)}</TableCell>
<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>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && pagination && (
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
aria-disabled={pagination.currentPage === 1}
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(i + 1); }}
isActive={pagination.currentPage === i + 1}
>
{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" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</motion.div>
)}
</motion.div>
);
}
export default Brands;

View File

@@ -1,428 +1,474 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback } from "react";
import { useQuery } 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 { motion } from "framer-motion"; // Using framer-motion as per previous examples
import config from "../config";
import { Skeleton } from "@/components/ui/skeleton"; // For loading states
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
interface Category {
cat_id: number;
name: string;
type: number;
parent_id: number | null;
parent_name: string | null;
parent_type: number | null;
description: string | null;
status: string;
metrics?: {
// Keep your existing type labels or fetch dynamically if preferred
const TYPE_LABELS: Record<number, string> = {
10: 'Section', 11: 'Category', 12: 'Subcategory', 13: 'Sub-subcategory',
1: 'Company', 2: 'Line', 3: 'Subline', 40: 'Artist',
// Add other types as needed
};
// Matches backend COLUMN_MAP keys for sorting
type CategorySortableColumns =
| 'categoryName' | 'categoryType' | 'parentName' | 'productCount'
| 'activeProductCount' | 'currentStockUnits' | 'currentStockCost'
| 'revenue_7d' | 'revenue_30d' | 'profit_30d' | 'sales_30d'
| 'avg_margin_30d' | 'stock_turn_30d';
interface CategoryMetric {
// Assuming category_id is unique primary identifier in category_metrics
category_id: string | number;
category_name: string;
category_type: number;
parent_id: string | number | null;
parent_name?: string | null; // Included conditionally by backend JOIN
last_calculated: string;
product_count: number;
active_products: number;
total_value: number;
avg_margin: number;
turnover_rate: number;
growth_rate: number;
};
active_product_count: number;
replenishable_product_count: number;
current_stock_units: number;
current_stock_cost: string | number;
current_stock_retail: string | number;
sales_7d: number;
revenue_7d: string | number;
sales_30d: number;
revenue_30d: string | number;
profit_30d: string | number;
cogs_30d: string | number;
sales_365d: number;
revenue_365d: string | number;
lifetime_sales: number;
lifetime_revenue: string | number;
avg_margin_30d: string | number | null;
stock_turn_30d: string | number | null;
// Camel case versions
categoryId: string | number;
categoryName: string;
categoryType: number;
parentId: string | number | null;
parentName?: string | null;
lastCalculated: string;
productCount: number;
activeProductCount: number;
replenishableProductCount: number;
currentStockUnits: number;
currentStockCost: string | number;
currentStockRetail: string | number;
lifetimeSales: number;
lifetimeRevenue: string | number;
avgMargin_30d: string | number | null;
stockTurn_30d: string | number | null;
}
interface TypeCount {
type: number;
count: number;
// Define response type to avoid type errors
interface CategoryResponse {
categories: CategoryMetric[];
pagination: {
total: number;
pages: number;
currentPage: number;
limit: number;
};
}
interface CategoryFilterOptions {
types: { value: number; label: string }[];
// statuses?: string[]; // Status filter removed as status is not on metrics table
}
interface CategoryStats {
totalCategories: number;
activeCategories: number;
totalActiveProducts: number;
totalValue: number;
avgMargin: number;
}
interface CategoryFilters {
search: string;
type: string;
performance: string;
search: string;
type: string; // Store type value as string for 'all' option
showInactive: boolean; // New filter for showing categories with 0 active products
}
const TYPE_LABELS: Record<number, string> = {
10: 'Section',
11: 'Category',
12: 'Subcategory',
13: 'Sub-subcategory',
20: 'Theme',
21: 'Subtheme'
const ITEMS_PER_PAGE = 50; // Consistent with backend default
// Helper for formatting
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(parsed);
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits,
}).format(value);
};
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return parsed.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)}%`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)}%`;
};
function getCategoryStatusVariant(status: string): "default" | "secondary" | "destructive" | "outline" {
switch (status.toLowerCase()) {
case 'active':
return 'default';
case 'inactive':
return 'secondary';
case 'archived':
return 'destructive';
default:
return 'outline';
}
}
export function Categories() {
const [page, setPage] = useState(1);
const [sortColumn] = useState<keyof Category>("name");
const [sortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<CategoryFilters>({
search: "",
type: "all",
performance: "all",
});
const { data, isLoading } = useQuery({
queryKey: ["categories"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/categories`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch categories");
return response.json();
},
});
// 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 type filter
if (filters.type !== 'all') {
filtered = filtered.filter(category => category.type === parseInt(filters.type));
}
// Apply performance filter
if (filters.performance !== 'all') {
filtered = filtered.filter(category => {
const growth = category.metrics?.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) => {
// First sort by type if not explicitly sorting by another column
if (sortColumn === "name") {
if (a.type !== b.type) {
return a.type - b.type;
}
// Then by parent hierarchy
if (a.parent_id !== b.parent_id) {
if (!a.parent_id) return -1;
if (!b.parent_id) return 1;
return a.parent_id - b.parent_id;
}
}
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);
const [page, setPage] = useState(1);
const [limit] = useState(ITEMS_PER_PAGE);
const [sortColumn, setSortColumn] = useState<CategorySortableColumns>("categoryName");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<CategoryFilters>({
search: "",
type: "all",
showInactive: false, // Default to hiding categories with 0 active products
});
return filtered;
}, [data?.categories, filters, sortColumn, sortDirection]);
// --- Data Fetching ---
// Calculate pagination
const totalPages = Math.ceil(filteredData.length / 50);
const paginatedData = useMemo(() => {
const start = (page - 1) * 50;
const end = start + 50;
return filteredData.slice(start, end);
}, [filteredData, page]);
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('limit', limit.toString());
params.set('sort', sortColumn);
params.set('order', sortDirection);
// 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.metrics?.total_value || 0), 0);
const margins = filteredData.map(c => c.metrics?.avg_margin || 0).filter(m => m !== 0);
const growthRates = filteredData.map(c => c.metrics?.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 formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
return (
<motion.div
layout
transition={{
layout: {
duration: 0.15,
ease: [0.4, 0, 0.2, 1] // Material Design easing
if (filters.search) {
params.set('categoryName_ilike', filters.search); // Use ILIKE for case-insensitive search
}
}}
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>
<div className="text-sm text-muted-foreground">
{filteredData.length.toLocaleString()} categories
</div>
</motion.div>
if (filters.type !== 'all') {
params.set('categoryType_eq', filters.type); // Use exact match for type
}
if (!filters.showInactive) {
params.set('activeProductCount_gt', '0'); // Only show categories with active products
}
// Add more filters here if needed, mapping to backend keys (e.g., revenue30d_gt=1000)
<motion.div
layout="preserve-aspect"
transition={{ duration: 0.15 }}
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">{stats?.totalCategories ?? "..."}</div>
<p className="text-xs text-muted-foreground">
{stats?.activeCategories ?? "..."} active
</p>
</CardContent>
</Card>
return params;
}, [page, limit, sortColumn, sortDirection, filters]);
<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>
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<CategoryResponse, Error>({
queryKey: ['categories', queryParams.toString()], // Use stringified params as key part
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/categories-aggregate?${queryParams.toString()}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Network response was not ok (${response.status})`);
}
const data = await response.json();
console.log('Categories data:', JSON.stringify(data, null, 2));
return data;
},
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
});
<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">{typeof data?.stats?.avgMargin === 'number' ? data.stats.avgMargin.toFixed(1) : "..."}%</div>
<p className="text-xs text-muted-foreground">
Across all categories
</p>
</CardContent>
</Card>
const { data: statsData, isLoading: isLoadingStats } = useQuery<CategoryStats, Error>({
queryKey: ['categoriesStats'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/categories-aggregate/stats`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch category stats");
return response.json();
},
});
<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">{typeof data?.stats?.avgGrowth === 'number' ? data.stats.avgGrowth.toFixed(1) : "..."}%</div>
<p className="text-xs text-muted-foreground">
Year over year
</p>
</CardContent>
</Card>
</motion.div>
const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<CategoryFilterOptions, Error>({
queryKey: ['categoriesFilterOptions'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/categories-aggregate/filter-options`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch category filter options");
return response.json();
},
});
<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.type}
onValueChange={(value) => setFilters(prev => ({ ...prev, type: value }))}
>
<SelectTrigger className="h-8 w-[180px]">
<SelectValue placeholder="Category Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{data?.typeCounts?.map((tc: TypeCount) => (
<SelectItem key={tc.type} value={tc.type.toString()}>
{TYPE_LABELS[tc.type]} ({tc.count})
</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>
// --- Event Handlers ---
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Name</TableHead>
<TableHead>Parent</TableHead>
<TableHead className="text-right">Products</TableHead>
<TableHead className="text-right">Active</TableHead>
<TableHead className="text-right">Value</TableHead>
<TableHead className="text-right">Margin</TableHead>
<TableHead className="text-right">Turnover</TableHead>
<TableHead className="text-right">Growth</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={10} className="text-center py-8">
Loading categories...
</TableCell>
</TableRow>
) : paginatedData.map((category: Category) => (
<TableRow key={category.cat_id}>
<TableCell>
<Badge variant="outline">
{TYPE_LABELS[category.type]}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{category.name}</span>
const handleSort = useCallback((column: CategorySortableColumns) => {
setSortDirection(prev => {
if (sortColumn !== column) return "asc";
return prev === "asc" ? "desc" : "asc";
});
setSortColumn(column);
setPage(1); // Reset to first page on sort change
}, [sortColumn]);
</div>
{category.description && (
<div className="text-xs text-muted-foreground">{category.description}</div>
)}
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{category.type === 10 ? category.name : // Section
category.type === 11 ? `${category.parent_name}` : // Category
category.type === 12 ? `${category.parent_name} > ${category.name}` : // Subcategory
category.type === 13 ? `${category.parent_name} > ${category.name}` : // Sub-subcategory
category.parent_name ? `${category.parent_name} > ${category.name}` : category.name}
</TableCell>
<TableCell className="text-right">{category.metrics?.product_count || 0}</TableCell>
<TableCell className="text-right">{category.metrics?.active_products || 0}</TableCell>
<TableCell className="text-right">{formatCurrency(category.metrics?.total_value || 0)}</TableCell>
<TableCell className="text-right">{category.metrics?.avg_margin?.toFixed(1)}%</TableCell>
<TableCell className="text-right">{category.metrics?.turnover_rate?.toFixed(2)}</TableCell>
<TableCell className="text-right">{category.metrics?.growth_rate?.toFixed(1)}%</TableCell>
<TableCell>
<Badge variant={getCategoryStatusVariant(category.status)}>
{category.status}
</Badge>
</TableCell>
</TableRow>
))}
{!isLoading && !paginatedData.length && (
<TableRow>
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
No categories found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
const handleFilterChange = useCallback((filterName: keyof CategoryFilters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [filterName]: value }));
setPage(1); // Reset to first page on filter change
}, []);
{totalPages > 1 && (
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
setPage(newPage);
}
};
// --- Derived Data ---
const categories = listData?.categories ?? [];
const pagination = listData?.pagination;
const totalPages = pagination?.pages ?? 0;
// --- Rendering ---
return (
<motion.div
layout="position"
transition={{ duration: 0.15 }}
className="flex justify-center"
layout
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
className="container mx-auto py-6 space-y-4"
>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (page > 1) setPage(p => p - 1);
}}
aria-disabled={page === 1}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setPage(i + 1);
}}
isActive={page === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (page < totalPages) setPage(p => p + 1);
}}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
{/* Header */}
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
<div className="text-sm text-muted-foreground">
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} categories`}
</div>
</motion.div>
{/* Stats Cards */}
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} 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>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalCategories)}</div>}
<p className="text-xs text-muted-foreground">
{isLoadingStats ? <Skeleton className="h-4 w-16 mt-1" /> : `${formatNumber(statsData?.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 Stock Value</CardTitle>
</CardHeader>
<CardContent>
{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
</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 (30d)</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-20" /> : <div className="text-2xl font-bold">{formatPercentage(statsData?.avgMargin)}</div>}
<p className="text-xs text-muted-foreground">
Weighted by revenue
</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 Active Products</CardTitle>
</CardHeader>
<CardContent>
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalActiveProducts)}</div>}
<p className="text-xs text-muted-foreground">
Across all categories
</p>
</CardContent>
</Card>
{/* Note: Avg Growth card removed as data is not available from the new /stats endpoint */}
</motion.div>
{/* Filter Controls */}
<div className="flex items-center justify-between">
<div className="flex flex-1 items-center justify-between space-x-2">
<div className="flex items-center space-x-2">
<Input
placeholder="Search categories..."
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
className="w-[150px] lg:w-[250px]"
/>
<Select
value={filters.type}
onValueChange={(value) => handleFilterChange('type', value)}
disabled={isLoadingFilterOptions}
>
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Category Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{filterOptions?.types?.map((typeOpt) => (
<SelectItem key={typeOpt.value} value={typeOpt.value.toString()}>
{typeOpt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2">
<Switch
id="show-inactive-categories"
checked={filters.showInactive}
onCheckedChange={(checked) => handleFilterChange('showInactive', checked)}
/>
<Label htmlFor="show-inactive-categories">Show categories with no active products</Label>
</div>
</div>
</div>
{/* Data Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{/* Add sorting indicators (arrows) for better UX */}
<TableHead onClick={() => handleSort("categoryType")} className="cursor-pointer">Type</TableHead>
<TableHead onClick={() => handleSort("categoryName")} className="cursor-pointer">Name</TableHead>
<TableHead onClick={() => handleSort("parentName")} className="cursor-pointer">Parent</TableHead>
<TableHead onClick={() => handleSort("activeProductCount")} className="cursor-pointer text-right">Active Prod.</TableHead>
<TableHead onClick={() => handleSort("currentStockUnits")} className="cursor-pointer text-right">Stock Units</TableHead>
<TableHead onClick={() => handleSort("currentStockCost")} className="cursor-pointer text-right">Stock Cost</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("stock_turn_30d")} className="cursor-pointer text-right">Stock Turn (30d)</TableHead>
{/* Note: Status column removed */}
</TableRow>
</TableHeader>
<TableBody>
{isLoadingList && !listData ? (
Array.from({ length: 5 }).map((_, i) => ( // Skeleton rows
<TableRow key={`skel-${i}`}>
<TableCell><Skeleton className="h-5 w-20" /></TableCell>
<TableCell><Skeleton className="h-5 w-40" /></TableCell>
<TableCell><Skeleton className="h-5 w-32" /></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-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-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>
</TableRow>
))
) : listError ? (
<TableRow>
<TableCell colSpan={10} className="text-center py-8 text-destructive">
Error loading categories: {listError.message}
</TableCell>
</TableRow>
) : categories.length === 0 ? (
<TableRow>
<TableCell colSpan={10} className="text-center py-8 text-muted-foreground">
No categories found matching your criteria.
</TableCell>
</TableRow>
) : (
categories.map((category: CategoryMetric) => (
<TableRow key={category.category_id}>
<TableCell>
<Badge variant="outline">
{TYPE_LABELS[category.category_type] || `Type ${category.category_type}`}
</Badge>
</TableCell>
<TableCell className="font-medium">{category.category_name}</TableCell>
<TableCell className="text-sm text-muted-foreground">
{category.parent_name ?? (category.parent_id ? `ID: ${category.parent_id}`: 'N/A')}
</TableCell>
<TableCell className="text-right">{formatNumber(category.active_product_count || category.activeProductCount)}</TableCell>
<TableCell className="text-right">{formatNumber(category.current_stock_units || category.currentStockUnits)}</TableCell>
<TableCell className="text-right">{formatCurrency(category.current_stock_cost as number)}</TableCell>
<TableCell className="text-right">{formatCurrency(typeof category.revenue_30d === 'string' ? parseFloat(category.revenue_30d) : category.revenue_30d)}</TableCell>
<TableCell className="text-right">{formatCurrency(typeof category.profit_30d === 'string' ? parseFloat(category.profit_30d) : category.profit_30d)}</TableCell>
<TableCell className="text-right">{formatPercentage(category.avg_margin_30d as number)}</TableCell>
<TableCell className="text-right">{formatNumber(category.stock_turn_30d as number, 2)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && pagination && (
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
aria-disabled={pagination.currentPage === 1}
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{/* Basic pagination links - consider a more advanced version for many pages */}
{[...Array(totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(i + 1); }}
isActive={pagination.currentPage === i + 1}
>
{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" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</motion.div>
)}
</motion.div>
)}
</motion.div>
);
);
}
export default Categories;

View File

@@ -8,8 +8,8 @@ import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton
import { ProductDetail } from '@/components/products/ProductDetail';
import { ProductViews } from '@/components/products/ProductViews';
import { Button } from '@/components/ui/button';
import { Product } from '@/types/products';
import type { ColumnKey } from '@/components/products/ProductTable';
import { Product, ProductMetric, ProductMetricColumnKey } from '@/types/products';
import { getProductStatus } from '@/utils/productUtils';
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@@ -35,7 +35,7 @@ import { toast } from "sonner";
// Column definition type
interface ColumnDef {
key: ColumnKey;
key: ProductMetricColumnKey;
label: string;
group: string;
noLabel?: boolean;
@@ -45,171 +45,162 @@ interface ColumnDef {
// Define available columns with their groups
const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'image', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
{ key: 'imageUrl', label: 'Image', group: 'Basic Info', noLabel: true, width: 'w-[60px]' },
{ key: 'title', label: 'Name', group: 'Basic Info' },
{ key: 'SKU', label: 'SKU', group: 'Basic Info' },
{ key: 'sku', label: 'SKU', group: 'Basic Info' },
{ key: 'brand', label: 'Company', group: 'Basic Info' },
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
{ key: 'vendor_reference', label: 'Supplier #', group: 'Basic Info' },
{ key: 'barcode', label: 'UPC', group: 'Basic Info' },
{ key: 'description', label: 'Description', group: 'Basic Info' },
{ key: 'created_at', label: 'Created', group: 'Basic Info' },
{ key: 'harmonized_tariff_code', label: 'HTS Code', group: 'Basic Info' },
{ key: 'notions_reference', label: 'Notions Ref', group: 'Basic Info' },
{ key: 'line', label: 'Line', group: 'Basic Info' },
{ key: 'subline', label: 'Subline', group: 'Basic Info' },
{ key: 'artist', label: 'Artist', group: 'Basic Info' },
{ key: 'country_of_origin', label: 'Origin', group: 'Basic Info' },
{ key: 'location', label: 'Location', group: 'Basic Info' },
{ key: 'isVisible', label: 'Visible', group: 'Basic Info' },
{ key: 'isReplenishable', label: 'Replenishable', group: 'Basic Info' },
{ key: 'dateCreated', label: 'Created', group: 'Basic Info' },
// Physical properties
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v?.toString() ?? '-' },
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (v) => v ? `${v.length}x${v.width}x${v.height}` : '-' },
// Current Status
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' },
// Stock columns
{ key: 'stock_quantity', label: 'Shelf Count', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'stock_status', label: 'Stock Status', group: 'Stock' },
{ key: 'preorder_count', label: 'Preorders', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'notions_inv_count', label: 'Notions Inv', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'days_of_inventory', label: 'Days of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'weeks_of_inventory', label: 'Weeks of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'abc_class', label: 'ABC Class', group: 'Stock' },
{ key: 'replenishable', label: 'Replenishable', group: 'Stock' },
{ key: 'moq', label: 'MOQ', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'reorder_qty', label: 'Reorder Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'reorder_point', label: 'Reorder Point', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'safety_stock', label: 'Safety Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'overstocked_amt', label: 'Overstock Amt', group: 'Stock', format: (v) => v?.toString() ?? '-' },
// Dates
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v?.toString() ?? '-' },
// Pricing columns
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'regular_price', label: 'Default Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cost_price', label: 'Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'landing_cost_price', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
// Product Status
{ key: 'status', label: 'Status', group: 'Status' },
// Sales columns
{ key: 'daily_sales_avg', label: 'Daily Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'weekly_sales_avg', label: 'Weekly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'monthly_sales_avg', label: 'Monthly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'avg_quantity_per_order', label: 'Avg Qty/Order', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'number_of_orders', label: 'Order Count', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales' },
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales' },
{ key: 'date_last_sold', label: 'Date Last Sold', group: 'Sales' },
{ key: 'total_sold', label: 'Total Sold', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'baskets', label: 'In Baskets', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'notifies', label: 'Notifies', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'rating', label: 'Rating', group: 'Sales', format: (v) => v ? v.toFixed(1) : '-' },
{ key: 'reviews', label: 'Reviews', group: 'Sales', format: (v) => v?.toString() ?? '-' },
// Rolling Metrics
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
// Financial columns
{ key: 'gmroi', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'avg_margin_percent', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'inventory_value', label: 'Inventory Value', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'gross_profit', label: 'Gross Profit', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
// KPIs
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
// Lead Time columns
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Lead Time' },
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Lead Time' },
{ key: 'first_received_date', label: 'First Received', group: 'Lead Time' },
{ key: 'last_received_date', label: 'Last Received', group: 'Lead Time' },
// Replenishment
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' },
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'isOldStock', label: 'Old Stock', group: 'Stock' },
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v?.toString() ?? '-' },
];
// Define default columns for each view
const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
all: [
'image',
'imageUrl',
'title',
'brand',
'vendor',
'stock_quantity',
'stock_status',
'reorder_qty',
'price',
'regular_price',
'daily_sales_avg',
'weekly_sales_avg',
'monthly_sales_avg',
'inventory_value',
'currentStock',
'status',
'salesVelocityDaily',
'currentPrice',
'currentRegularPrice',
'sales7d',
'sales30d',
'revenue30d',
'currentStockCost',
],
critical: [
'image',
'imageUrl',
'title',
'stock_quantity',
'safety_stock',
'daily_sales_avg',
'weekly_sales_avg',
'reorder_qty',
'reorder_point',
'currentStock',
'configSafetyStock',
'sales7d',
'sales30d',
'replenishmentUnits',
'salesVelocityDaily',
'vendor',
'last_purchase_date',
'current_lead_time',
'dateLastReceived',
'avgLeadTimeDays',
],
reorder: [
'image',
'imageUrl',
'title',
'stock_quantity',
'reorder_point',
'daily_sales_avg',
'weekly_sales_avg',
'reorder_qty',
'currentStock',
'salesVelocityDaily',
'sales7d',
'sales30d',
'replenishmentUnits',
'vendor',
'last_purchase_date',
'avg_lead_time_days',
'dateLastReceived',
'avgLeadTimeDays',
],
overstocked: [
'image',
'imageUrl',
'title',
'stock_quantity',
'daily_sales_avg',
'weekly_sales_avg',
'overstocked_amt',
'days_of_inventory',
'inventory_value',
'turnover_rate',
'currentStock',
'sales7d',
'sales30d',
'overstockedUnits',
'stockCoverInDays',
'currentStockCost',
'stockturn30d',
],
'at-risk': [
'image',
'imageUrl',
'title',
'stock_quantity',
'safety_stock',
'daily_sales_avg',
'weekly_sales_avg',
'days_of_inventory',
'last_sale_date',
'current_lead_time',
'currentStock',
'configSafetyStock',
'sales7d',
'sales30d',
'stockCoverInDays',
'dateLastSold',
'avgLeadTimeDays',
],
new: [
'image',
'imageUrl',
'title',
'stock_quantity',
'currentStock',
'vendor',
'brand',
'price',
'regular_price',
'first_received_date',
'currentPrice',
'currentRegularPrice',
'dateFirstReceived',
],
healthy: [
'image',
'imageUrl',
'title',
'stock_quantity',
'daily_sales_avg',
'weekly_sales_avg',
'monthly_sales_avg',
'days_of_inventory',
'gross_profit',
'gmroi',
'currentStock',
'sales7d',
'sales30d',
'revenue30d',
'stockCoverInDays',
'profit30d',
'gmroi30d',
],
};
export function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
const [sortColumn, setSortColumn] = useState<ColumnKey>('title');
const [sortColumn, setSortColumn] = useState<ProductMetricColumnKey>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [currentPage, setCurrentPage] = useState(1);
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
@@ -219,16 +210,16 @@ export function Products() {
const [, setIsLoading] = useState(false);
// Store visible columns and order for each view
const [viewColumns, setViewColumns] = useState<Record<string, Set<ColumnKey>>>(() => {
const initialColumns: Record<string, Set<ColumnKey>> = {};
const [viewColumns, setViewColumns] = useState<Record<string, Set<ProductMetricColumnKey>>>(() => {
const initialColumns: Record<string, Set<ProductMetricColumnKey>> = {};
Object.entries(VIEW_COLUMNS).forEach(([view, columns]) => {
initialColumns[view] = new Set(columns);
});
return initialColumns;
});
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ColumnKey[]>>(() => {
const initialOrder: Record<string, ColumnKey[]> = {};
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ProductMetricColumnKey[]>>(() => {
const initialOrder: Record<string, ProductMetricColumnKey[]> = {};
Object.entries(VIEW_COLUMNS).forEach(([view, defaultColumns]) => {
initialOrder[view] = [
...defaultColumns,
@@ -241,16 +232,19 @@ export function Products() {
// Get current view's columns
const visibleColumns = useMemo(() => {
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
// Add isReplenishable column when showing non-replenishable products for better visibility
if (showNonReplenishable) {
columns.add('replenishable');
columns.add('isReplenishable');
}
return columns;
}, [viewColumns, activeView, showNonReplenishable]);
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
// Handle column visibility changes
const handleColumnVisibilityChange = (column: ColumnKey, isVisible: boolean) => {
const handleColumnVisibilityChange = (column: ProductMetricColumnKey, isVisible: boolean) => {
setViewColumns(prev => ({
...prev,
[activeView]: isVisible
@@ -260,7 +254,7 @@ export function Products() {
};
// Handle column order changes
const handleColumnOrderChange = (newOrder: ColumnKey[]) => {
const handleColumnOrderChange = (newOrder: ProductMetricColumnKey[]) => {
setViewColumnOrder(prev => ({
...prev,
[activeView]: newOrder
@@ -307,35 +301,93 @@ export function Products() {
params.append('limit', pageSize.toString());
if (sortColumn) {
params.append('sort', sortColumn);
// Convert camelCase to snake_case for the API
const snakeCaseSort = sortColumn.replace(/([A-Z])/g, '_$1').toLowerCase();
params.append('sort', snakeCaseSort);
params.append('order', sortDirection);
}
if (activeView && activeView !== 'all') {
params.append('stockStatus', activeView === 'at-risk' ? 'At Risk' : activeView);
params.append('stock_status', activeView === 'at-risk' ? 'At Risk' : activeView);
}
// Transform filters to match API expectations
const transformedFilters = transformFilters(filters);
Object.entries(transformedFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
// Convert camelCase to snake_case for the API
const snakeCaseKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
if (Array.isArray(value)) {
params.append(key, JSON.stringify(value));
params.append(snakeCaseKey, JSON.stringify(value));
} else {
params.append(key, value.toString());
params.append(snakeCaseKey, value.toString());
}
}
});
if (!showNonReplenishable) {
params.append('showNonReplenishable', 'false');
params.append('show_non_replenishable', 'false');
}
const response = await fetch(`/api/products?${params.toString()}`);
const response = await fetch(`/api/metrics?${params.toString()}`);
if (!response.ok) throw new Error('Failed to fetch products');
const data = await response.json();
return data;
// Transform snake_case keys to camelCase and convert string numbers to actual numbers
const transformedProducts = data.metrics?.map((product: any) => {
const transformed: any = {};
// Process all keys to convert from snake_case to camelCase
Object.entries(product).forEach(([key, value]) => {
// Better handling of snake_case to camelCase conversion
let camelKey = key;
// First handle cases like sales_7d -> sales7d
camelKey = camelKey.replace(/_(\d+[a-z])/g, '$1');
// Then handle regular snake_case -> camelCase
camelKey = camelKey.replace(/_([a-z])/g, (_, p1) => p1.toUpperCase());
// Convert numeric strings to actual numbers
if (typeof value === 'string' && !isNaN(Number(value)) &&
!key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' &&
key !== 'brand' && key !== 'vendor') {
transformed[camelKey] = Number(value);
} else {
transformed[camelKey] = value;
}
});
// Ensure pid is a number
transformed.pid = typeof transformed.pid === 'string' ?
parseInt(transformed.pid, 10) : transformed.pid;
return transformed;
}) || [];
// Debug: Log the first item to check field mapping
if (transformedProducts.length > 0) {
console.log('Sample product after transformation:');
console.log('sales7d:', transformedProducts[0].sales7d);
console.log('sales30d:', transformedProducts[0].sales30d);
console.log('revenue30d:', transformedProducts[0].revenue30d);
console.log('margin30d:', transformedProducts[0].margin30d);
console.log('markup30d:', transformedProducts[0].markup30d);
}
// Transform the metrics response to match our expected format
return {
products: transformedProducts,
pagination: data.pagination || {
total: 0,
pages: 0,
currentPage: 1,
limit: pageSize
},
filters: data.appliedQuery?.filters || {}
};
} catch (error) {
console.error('Error fetching products:', error);
toast("Failed to fetch products. Please try again.");
@@ -345,6 +397,29 @@ export function Products() {
}
};
// Query for filter options
const { data: filterOptionsData, isLoading: isLoadingFilterOptions } = useQuery({
queryKey: ['filterOptions'],
queryFn: async () => {
try {
const response = await fetch('/api/metrics/filter-options');
if (!response.ok) throw new Error('Failed to fetch filter options');
const data = await response.json();
// Ensure we have the expected structure with correct casing
return {
vendors: data.vendors || [],
brands: data.brands || [],
abcClasses: data.abc_classes || data.abcClasses || []
};
} catch (error) {
console.error('Error fetching filter options:', error);
return { vendors: [], brands: [], abcClasses: [] };
}
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Query for products data
const { data, isFetching } = useQuery({
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable],
@@ -360,7 +435,7 @@ export function Products() {
}, [currentPage, data?.pagination.pages]);
// Handle sort column change
const handleSort = (column: keyof Product) => {
const handleSort = (column: ProductMetricColumnKey) => {
setSortDirection(prev => {
if (sortColumn !== column) return 'asc';
return prev === 'asc' ? 'desc' : 'asc';
@@ -515,9 +590,12 @@ export function Products() {
<div>
<div className="flex items-center justify-between mb-4">
<ProductFilters
categories={data?.filters?.categories ?? []}
vendors={data?.filters?.vendors ?? []}
brands={data?.filters?.brands ?? []}
filterOptions={{
vendors: filterOptionsData?.vendors ?? [],
brands: filterOptionsData?.brands ?? [],
abcClasses: filterOptionsData?.abcClasses ?? []
}}
isLoadingOptions={isLoadingFilterOptions}
onFilterChange={handleFilterChange}
onClearFilters={handleClearFilters}
activeFilters={filters}
@@ -534,7 +612,7 @@ export function Products() {
/>
<Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label>
</div>
{data?.pagination.total > 0 && (
{data?.pagination?.total !== undefined && (
<div className="text-sm text-muted-foreground">
{data.pagination.total.toLocaleString()} products
</div>
@@ -548,7 +626,13 @@ export function Products() {
) : (
<div className="space-y-4">
<ProductTable
products={data?.products || []}
products={data?.products?.map((product: ProductMetric) => {
// Before returning the product, ensure it has a status for display
if (!product.status) {
product.status = getProductStatus(product);
}
return product;
}) || []}
onSort={handleSort}
sortColumn={sortColumn}
sortDirection={sortDirection}

View File

@@ -1,350 +1,430 @@
import { useState, useMemo } from "react";
import { useState, useMemo, useCallback } from "react";
import { useQuery } 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 { 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 { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { motion } from "framer-motion";
import config from "../config";
import { Skeleton } from "@/components/ui/skeleton";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
interface Vendor {
vendor_id: number;
name: string;
status: string;
avg_lead_time_days: number;
on_time_delivery_rate: number;
order_fill_rate: number;
total_orders: number;
active_products: number;
avg_unit_cost: number;
total_spend: number;
// Matches backend COLUMN_MAP keys for sorting
type VendorSortableColumns =
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d';
interface VendorMetric {
// Assuming vendor_name is unique primary identifier
vendor_name: string;
last_calculated: string;
product_count: number;
active_product_count: number;
replenishable_product_count: number;
current_stock_units: number;
current_stock_cost: string | number;
current_stock_retail: string | number;
on_order_units: number;
on_order_cost: string | number;
po_count_365d: number;
avg_lead_time_days: number | null;
sales_7d: number;
revenue_7d: string | number;
sales_30d: number;
revenue_30d: string | number;
profit_30d: string | number;
cogs_30d: string | number;
sales_365d: number;
revenue_365d: string | number;
lifetime_sales: number;
lifetime_revenue: string | number;
avg_margin_30d: string | number | null;
// Camel case versions
vendorName: string;
lastCalculated: string;
productCount: number;
activeProductCount: number;
replenishableProductCount: number;
currentStockUnits: number;
currentStockCost: string | number;
currentStockRetail: string | number;
onOrderUnits: number;
onOrderCost: string | number;
poCount_365d: number;
avgLeadTimeDays: number | null;
lifetimeSales: number;
lifetimeRevenue: string | number;
avgMargin_30d: string | number | null;
}
// Define response type to avoid type errors
interface VendorResponse {
vendors: VendorMetric[];
pagination: {
total: number;
pages: number;
currentPage: number;
limit: number;
};
}
// Filter options are just a list of names, not useful for dropdowns here
// interface VendorFilterOptions { vendors: 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)
}
interface VendorFilters {
search: string;
status: string;
performance: string;
search: string;
showInactive: boolean; // New filter for showing vendors with 0 active products
// Status and Performance filters removed
}
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') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(parsed);
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
const formatNumber = (value: number | string | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return parsed.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
};
const formatPercentage = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)}%`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)}%`;
};
const formatDays = (value: number | string | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
if (typeof value === 'string') {
const parsed = parseFloat(value);
if (isNaN(parsed)) return 'N/A';
return `${parsed.toFixed(digits)} days`;
}
if (typeof value !== 'number' || isNaN(value)) return 'N/A';
return `${value.toFixed(digits)} days`;
};
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"],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch vendors");
return response.json();
},
});
// 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)
);
}
// 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);
const [page, setPage] = useState(1);
const [limit] = useState(ITEMS_PER_PAGE);
const [sortColumn, setSortColumn] = useState<VendorSortableColumns>("vendorName");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const [filters, setFilters] = useState<VendorFilters>({
search: "",
showInactive: false, // Default to hiding vendors with 0 active products
});
return filtered;
}, [data?.vendors, filters, sortColumn, sortDirection]);
// --- Data Fetching ---
// Calculate pagination
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
const paginatedData = useMemo(() => {
const start = (page - 1) * ITEMS_PER_PAGE;
const end = start + ITEMS_PER_PAGE;
return filteredData.slice(start, end);
}, [filteredData, page]);
const queryParams = useMemo(() => {
const params = new URLSearchParams();
params.set('page', page.toString());
params.set('limit', limit.toString());
params.set('sort', sortColumn);
params.set('order', sortDirection);
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
transition={{
layout: {
duration: 0.15,
ease: [0.4, 0, 0.2, 1]
if (filters.search) {
params.set('vendorName_ilike', filters.search); // Filter by name
}
}}
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>
<div className="text-sm text-muted-foreground">
{filteredData.length.toLocaleString()} vendors
</div>
</motion.div>
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)
<motion.div
layout="preserve-aspect"
transition={{ duration: 0.15 }}
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>
return params;
}, [page, limit, sortColumn, sortDirection, filters]);
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Spend</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${typeof data?.stats?.totalSpend === 'number' ? data.stats.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "..."}
</div>
<p className="text-xs text-muted-foreground">
Avg unit cost: ${typeof data?.stats?.avgUnitCost === 'number' ? data.stats.avgUnitCost.toFixed(2) : "..."}
</p>
</CardContent>
</Card>
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<VendorResponse, Error>({
queryKey: ['vendors', queryParams.toString()],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate?${queryParams.toString()}`, {
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;
},
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
});
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Performance</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{typeof data?.stats?.avgFillRate === 'number' ? data.stats.avgFillRate.toFixed(1) : "..."}%</div>
<p className="text-xs text-muted-foreground">
Fill rate / {typeof data?.stats?.avgOnTimeDelivery === 'number' ? data.stats.avgOnTimeDelivery.toFixed(1) : "..."}% on-time
</p>
</CardContent>
</Card>
const { data: statsData, isLoading: isLoadingStats } = useQuery<VendorStats, Error>({
queryKey: ['vendorsStats'],
queryFn: async () => {
const response = await fetch(`${config.apiUrl}/vendors-aggregate/stats`, {
credentials: 'include'
});
if (!response.ok) throw new Error("Failed to fetch vendor stats");
return response.json();
},
});
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Lead Time</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{typeof data?.stats?.avgLeadTime === 'number' ? data.stats.avgLeadTime.toFixed(1) : "..."} days</div>
<p className="text-xs text-muted-foreground">
Average delivery time
</p>
</CardContent>
</Card>
</motion.div>
// Filter options query might not be needed if only search is used
// const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<VendorFilterOptions, Error>({ ... });
<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>
// --- Event Handlers ---
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Vendor</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 %</TableHead>
<TableHead onClick={() => handleSort("order_fill_rate")} className="cursor-pointer">Fill Rate</TableHead>
<TableHead onClick={() => handleSort("avg_unit_cost")} className="cursor-pointer">Avg Unit Cost</TableHead>
<TableHead onClick={() => handleSort("total_spend")} className="cursor-pointer">Total Spend</TableHead>
<TableHead onClick={() => handleSort("total_orders")} className="cursor-pointer">Orders</TableHead>
<TableHead onClick={() => handleSort("active_products")} className="cursor-pointer">Products</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={9} className="text-center py-8">
Loading vendors...
</TableCell>
</TableRow>
) : paginatedData.map((vendor: Vendor) => (
<TableRow key={vendor.vendor_id}>
<TableCell className="font-medium">{vendor.name}</TableCell>
<TableCell>{vendor.status}</TableCell>
<TableCell>{typeof vendor.avg_lead_time_days === 'number' ? vendor.avg_lead_time_days.toFixed(1) : "0.0"} days</TableCell>
<TableCell>{typeof vendor.on_time_delivery_rate === 'number' ? vendor.on_time_delivery_rate.toFixed(1) : "0.0"}%</TableCell>
<TableCell>
<div className="flex items-center gap-2" style={{ minWidth: '120px' }}>
<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)}
</div>
</TableCell>
<TableCell>${typeof vendor.avg_unit_cost === 'number' ? vendor.avg_unit_cost.toFixed(2) : "0.00"}</TableCell>
<TableCell>${typeof vendor.total_spend === 'number' ? vendor.total_spend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "0"}</TableCell>
<TableCell>{vendor.total_orders?.toLocaleString() ?? 0}</TableCell>
<TableCell>{vendor.active_products?.toLocaleString() ?? 0}</TableCell>
</TableRow>
))}
{!isLoading && !paginatedData.length && (
<TableRow>
<TableCell colSpan={9} className="text-center py-8 text-muted-foreground">
No vendors found
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
const handleSort = useCallback((column: VendorSortableColumns) => {
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
setSortColumn(column);
setPage(1);
}, [sortColumn]);
{totalPages > 1 && (
const handleFilterChange = useCallback((filterName: keyof VendorFilters, value: string | boolean) => {
setFilters(prev => ({ ...prev, [filterName]: value }));
setPage(1);
}, []);
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= (listData?.pagination.pages ?? 1)) {
setPage(newPage);
}
};
// --- Derived Data ---
const vendors = listData?.vendors ?? [];
const pagination = listData?.pagination;
const totalPages = pagination?.pages ?? 0;
// --- Rendering ---
return (
<motion.div
layout="position"
transition={{ duration: 0.15 }}
className="flex justify-center"
layout
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
className="container mx-auto py-6 space-y-4"
>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => {
e.preventDefault();
if (page > 1) setPage(p => p - 1);
}}
aria-disabled={page === 1}
/>
</PaginationItem>
{Array.from({ length: totalPages }, (_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => {
e.preventDefault();
setPage(i + 1);
}}
isActive={page === i + 1}
>
{i + 1}
</PaginationLink>
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (page < totalPages) setPage(p => p + 1);
}}
aria-disabled={page >= totalPages}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
{/* Header */}
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
<div className="text-sm text-muted-foreground">
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} vendors`}
</div>
</motion.div>
{/* Stats Cards */}
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} 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>
{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
</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 Stock Value</CardTitle>
</CardHeader>
<CardContent>
{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
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Value On Order</CardTitle>
</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">
Total cost on open POs
</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>
{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 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 */}
</div>
</div>
{/* 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 */}
</TableRow>
</TableHeader>
<TableBody>
{isLoadingList && !listData ? (
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>
<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-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-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>
</TableRow>
))
) : listError ? (
<TableRow>
<TableCell colSpan={10} 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">
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 */}
<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>
<TableCell className="text-right">{formatNumber(vendor.on_order_units || vendor.onOrderUnits)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.on_order_cost as number)}</TableCell>
<TableCell className="text-right">{formatDays(vendor.avg_lead_time_days || vendor.avgLeadTimeDays)}</TableCell>
<TableCell className="text-right">{formatCurrency(vendor.revenue_30d as number)}</TableCell>
<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>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination Controls */}
{totalPages > 1 && pagination && (
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex justify-center">
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(pagination.currentPage - 1); }}
aria-disabled={pagination.currentPage === 1}
className={pagination.currentPage === 1 ? "pointer-events-none opacity-50" : ""}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, i) => (
<PaginationItem key={i + 1}>
<PaginationLink
href="#"
onClick={(e) => { e.preventDefault(); handlePageChange(i + 1); }}
isActive={pagination.currentPage === i + 1}
>
{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" : ""}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</motion.div>
)}
</motion.div>
)}
</motion.div>
);
);
}
export default Vendors;

View File

@@ -78,3 +78,196 @@ export interface Product {
reorder_qty?: number;
overstocked_amt?: string; // numeric(15,3)
}
// Type for product status (used for calculated statuses)
export type ProductStatus = "Critical" | "Reorder Soon" | "Healthy" | "Overstock" | "At Risk" | "Unknown";
// Represents data returned by the /metrics endpoint (from product_metrics table)
export interface ProductMetric {
pid: number;
sku: string;
title: string;
brand: string | null;
vendor: string | null;
imageUrl: string | null;
isVisible: boolean;
isReplenishable: boolean;
// Current Status
currentPrice: number | null;
currentRegularPrice: number | null;
currentCostPrice: number | null;
currentLandingCostPrice: number | null;
currentStock: number;
currentStockCost: number | null;
currentStockRetail: number | null;
currentStockGross: number | null;
onOrderQty: number | null;
onOrderCost: number | null;
onOrderRetail: number | null;
earliestExpectedDate: string | null; // Date as string
// Historical Dates
dateCreated: string | null;
dateFirstReceived: string | null;
dateLastReceived: string | null;
dateFirstSold: string | null;
dateLastSold: string | null;
ageDays: number | null;
// Rolling Period Metrics
sales7d: number | null;
revenue7d: number | null;
sales14d: number | null;
revenue14d: number | null;
sales30d: number | null;
revenue30d: number | null;
cogs30d: number | null;
profit30d: number | null;
returnsUnits30d: number | null;
returnsRevenue30d: number | null;
discounts30d: number | null;
grossRevenue30d: number | null;
grossRegularRevenue30d: number | null;
stockoutDays30d: number | null;
sales365d: number | null;
revenue365d: number | null;
avgStockUnits30d: number | null;
avgStockCost30d: number | null;
avgStockRetail30d: number | null;
avgStockGross30d: number | null;
receivedQty30d: number | null;
receivedCost30d: number | null;
// Calculated KPIs
asp30d: number | null;
acp30d: number | null;
avgRos30d: number | null;
avgSalesPerDay30d: number | null;
avgSalesPerMonth30d: number | null;
margin30d: number | null;
markup30d: number | null;
gmroi30d: number | null;
stockturn30d: number | null;
returnRate30d: number | null;
discountRate30d: number | null;
stockoutRate30d: number | null;
markdown30d: number | null;
markdownRate30d: number | null;
sellThrough30d: number | null;
avgLeadTimeDays: number | null;
// Forecasting & Replenishment
abcClass: string | null;
salesVelocityDaily: number | null;
configLeadTime: number | null;
configDaysOfStock: number | null;
configSafetyStock: number | null;
planningPeriodDays: number | null;
leadTimeForecastUnits: number | null;
daysOfStockForecastUnits: number | null;
planningPeriodForecastUnits: number | null;
leadTimeClosingStock: number | null;
daysOfStockClosingStock: number | null;
replenishmentNeededRaw: number | null;
replenishmentUnits: number | null;
replenishmentCost: number | null;
replenishmentRetail: number | null;
replenishmentProfit: number | null;
toOrderUnits: number | null;
forecastLostSalesUnits: number | null;
forecastLostRevenue: number | null;
stockCoverInDays: number | null;
poCoverInDays: number | null;
sellsOutInDays: number | null;
replenishDate: string | null;
overstockedUnits: number | null;
overstockedCost: number | null;
overstockedRetail: number | null;
isOldStock: boolean | null;
// Yesterday
yesterdaySales: number | null;
// Calculated status (added by frontend)
status?: ProductStatus;
}
// Type for filter options returned by /metrics/filter-options
export interface ProductFilterOptions {
vendors: string[];
brands: string[];
abcClasses: string[];
}
// Type for keys used in sorting/filtering (matching frontend state/UI)
export type ProductMetricColumnKey = keyof Omit<ProductMetric, 'pid'> | 'pid' | 'status';
// Mapping frontend keys to backend query param keys
export const FRONTEND_TO_BACKEND_KEY_MAP: Record<string, string> = {
pid: 'pid',
sku: 'sku',
title: 'title',
brand: 'brand',
vendor: 'vendor',
imageUrl: 'imageUrl',
isVisible: 'isVisible',
isReplenishable: 'isReplenishable',
currentPrice: 'currentPrice',
currentRegularPrice: 'currentRegularPrice',
currentCostPrice: 'currentCostPrice',
currentLandingCostPrice: 'currentLandingCostPrice',
currentStock: 'currentStock',
currentStockCost: 'currentStockCost',
currentStockRetail: 'currentStockRetail',
currentStockGross: 'currentStockGross',
onOrderQty: 'onOrderQty',
onOrderCost: 'onOrderCost',
onOrderRetail: 'onOrderRetail',
earliestExpectedDate: 'earliestExpectedDate',
dateCreated: 'dateCreated',
dateFirstReceived: 'dateFirstReceived',
dateLastReceived: 'dateLastReceived',
dateFirstSold: 'dateFirstSold',
dateLastSold: 'dateLastSold',
ageDays: 'ageDays',
sales7d: 'sales7d',
revenue7d: 'revenue7d',
sales14d: 'sales14d',
revenue14d: 'revenue14d',
sales30d: 'sales30d',
revenue30d: 'revenue30d',
cogs30d: 'cogs30d',
profit30d: 'profit30d',
stockoutDays30d: 'stockoutDays30d',
sales365d: 'sales365d',
revenue365d: 'revenue365d',
avgStockUnits30d: 'avgStockUnits30d',
avgStockCost30d: 'avgStockCost30d',
receivedQty30d: 'receivedQty30d',
receivedCost30d: 'receivedCost30d',
asp30d: 'asp30d',
acp30d: 'acp30d',
margin30d: 'margin30d',
gmroi30d: 'gmroi30d',
stockturn30d: 'stockturn30d',
sellThrough30d: 'sellThrough30d',
avgLeadTimeDays: 'avgLeadTimeDays',
abcClass: 'abcClass',
salesVelocityDaily: 'salesVelocityDaily',
configLeadTime: 'configLeadTime',
configDaysOfStock: 'configDaysOfStock',
stockCoverInDays: 'stockCoverInDays',
sellsOutInDays: 'sellsOutInDays',
replenishDate: 'replenishDate',
overstockedUnits: 'overstockedUnits',
overstockedCost: 'overstockedCost',
isOldStock: 'isOldStock',
yesterdaySales: 'yesterdaySales',
status: 'status' // Frontend-only field
};
// Function to get backend key safely
export function getBackendKey(frontendKey: string): string | null {
return FRONTEND_TO_BACKEND_KEY_MAP[frontendKey] || null;
}

View File

@@ -0,0 +1,134 @@
import { ProductMetric, ProductStatus } from "@/types/products";
//Calculates the product status based on various metrics
export function getProductStatus(product: ProductMetric): ProductStatus {
if (!product.isReplenishable) {
return "Healthy"; // Non-replenishable items default to Healthy
}
const {
currentStock,
stockCoverInDays,
sellsOutInDays,
overstockedUnits,
configLeadTime,
avgLeadTimeDays,
dateLastSold,
ageDays,
isOldStock
} = product;
const leadTime = configLeadTime ?? avgLeadTimeDays ?? 30; // Default lead time if none configured
const safetyThresholdDays = leadTime * 0.5; // Safety threshold is 50% of lead time
// Check for overstock first
if (overstockedUnits != null && overstockedUnits > 0) {
return "Overstock";
}
// Check for critical stock
if (stockCoverInDays != null) {
// Stock is <= 0 or very low compared to lead time
if (currentStock <= 0 || stockCoverInDays <= 0) {
return "Critical";
}
if (stockCoverInDays < safetyThresholdDays) {
return "Critical";
}
}
// Check for products that will need reordering soon
if (sellsOutInDays != null && sellsOutInDays < (leadTime + 7)) { // Within lead time + 1 week
// If also critically low, keep Critical status
if (stockCoverInDays != null && stockCoverInDays < safetyThresholdDays) {
return "Critical";
}
return "Reorder Soon";
}
// Check for 'At Risk' - e.g., old stock or hasn't sold in a long time
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
if (isOldStock) {
return "At Risk";
}
if (dateLastSold && new Date(dateLastSold) < ninetyDaysAgo && (ageDays ?? 0) > 180) {
return "At Risk";
}
// Very high stock cover (more than a year) is at risk too
if (stockCoverInDays != null && stockCoverInDays > 365) {
return "At Risk";
}
// If none of the above, assume Healthy
return "Healthy";
}
//Returns a Badge component HTML string for a given product status
export function getStatusBadge(status: ProductStatus): string {
switch (status) {
case 'Critical':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-red-600 text-white">Critical</div>';
case 'Reorder Soon':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-yellow-500 text-black">Reorder Soon</div>';
case 'Healthy':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-green-600 text-white">Healthy</div>';
case 'Overstock':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-secondary bg-blue-600 text-white">Overstock</div>';
case 'At Risk':
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-orange-500 text-orange-600">At Risk</div>';
default:
return '<div class="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">Unknown</div>';
}
}
//Formatting utilities for displaying metrics
export const formatCurrency = (value: number | null | undefined, digits = 2): string => {
if (value == null) return 'N/A';
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: digits,
maximumFractionDigits: digits
}).format(value);
};
export const formatNumber = (value: number | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
return value.toLocaleString(undefined, {
minimumFractionDigits: digits,
maximumFractionDigits: digits
});
};
export const formatPercentage = (value: number | null | undefined, digits = 1): string => {
if (value == null) return 'N/A';
return `${value.toFixed(digits)}%`;
};
export const formatDays = (value: number | null | undefined, digits = 0): string => {
if (value == null) return 'N/A';
return `${value.toFixed(digits)} days`;
};
export const formatDate = (dateString: string | null | undefined): string => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch (e) {
return 'Invalid Date';
}
};
export const formatBoolean = (value: boolean | null | undefined): string => {
if (value == null) return 'N/A';
return value ? 'Yes' : 'No';
};