Update/add frontend pages for categories, brands, vendors new routes, update products page to use new route
This commit is contained in:
@@ -192,7 +192,28 @@ router.get('/', async (req, res) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const total = parseInt(countResult.rows[0].total, 10);
|
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 ---
|
// --- Respond ---
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -222,17 +222,20 @@ router.get('/', async (req, res) => {
|
|||||||
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||||
|
|
||||||
// Need JOIN for parent_name if sorting/filtering by it, or always include for display
|
// 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 = `
|
const baseSql = `
|
||||||
FROM public.category_metrics cm
|
FROM public.category_metrics cm
|
||||||
${needParentJoin ? 'LEFT JOIN public.categories p ON cm.parent_id = p.cat_id' : ''}
|
${parentJoinSql}
|
||||||
${whereClause}
|
${whereClause}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
|
const countSql = `SELECT COUNT(*) AS total ${baseSql}`;
|
||||||
const dataSql = `
|
const dataSql = `
|
||||||
SELECT cm.* ${needParentJoin ? ', p.name as parent_name' : ''}
|
SELECT cm.*, p.name as parent_name
|
||||||
${baseSql}
|
${baseSql}
|
||||||
${sortClause}
|
${sortClause}
|
||||||
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
|
LIMIT $${paramCounter} OFFSET $${paramCounter + 1}
|
||||||
@@ -248,7 +251,28 @@ router.get('/', async (req, res) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const total = parseInt(countResult.rows[0].total, 10);
|
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 ---
|
// --- Respond ---
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -196,7 +196,28 @@ router.get('/', async (req, res) => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const total = parseInt(countResult.rows[0].total, 10);
|
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 ---
|
// --- Respond ---
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
20
inventory/package-lock.json
generated
20
inventory/package-lock.json
generated
@@ -61,6 +61,7 @@
|
|||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-data-grid": "^7.0.0-beta.13",
|
"react-data-grid": "^7.0.0-beta.13",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-debounce-input": "^3.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
@@ -6043,6 +6044,12 @@
|
|||||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"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"
|
"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": {
|
"node_modules/react-dom": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
|
|||||||
@@ -63,6 +63,7 @@
|
|||||||
"react-chartjs-2": "^5.3.0",
|
"react-chartjs-2": "^5.3.0",
|
||||||
"react-data-grid": "^7.0.0-beta.13",
|
"react-data-grid": "^7.0.0-beta.13",
|
||||||
"react-day-picker": "^8.10.1",
|
"react-day-picker": "^8.10.1",
|
||||||
|
"react-debounce-input": "^3.3.0",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-dropzone": "^14.3.5",
|
"react-dropzone": "^14.3.5",
|
||||||
"react-hook-form": "^7.54.2",
|
"react-hook-form": "^7.54.2",
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { Import } from '@/pages/Import';
|
|||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { Protected } from './components/auth/Protected';
|
import { Protected } from './components/auth/Protected';
|
||||||
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
|
import { FirstAccessiblePage } from './components/auth/FirstAccessiblePage';
|
||||||
|
import { Brands } from '@/pages/Brands';
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
@@ -108,6 +108,11 @@ function App() {
|
|||||||
<Vendors />
|
<Vendors />
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/brands" element={
|
||||||
|
<Protected page="brands">
|
||||||
|
<Brands />
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
<Route path="/purchase-orders" element={
|
<Route path="/purchase-orders" element={
|
||||||
<Protected page="purchase_orders">
|
<Protected page="purchase_orders">
|
||||||
<PurchaseOrders />
|
<PurchaseOrders />
|
||||||
|
|||||||
7
inventory/src/components/config.ts
Normal file
7
inventory/src/components/config.ts
Normal 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;
|
||||||
@@ -5,9 +5,10 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
LogOut,
|
LogOut,
|
||||||
Users,
|
|
||||||
Tags,
|
Tags,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
|
ShoppingBag,
|
||||||
|
Truck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -57,9 +58,15 @@ const items = [
|
|||||||
url: "/categories",
|
url: "/categories",
|
||||||
permission: "access:categories"
|
permission: "access:categories"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Brands",
|
||||||
|
icon: ShoppingBag,
|
||||||
|
url: "/brands",
|
||||||
|
permission: "access:brands"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Vendors",
|
title: "Vendors",
|
||||||
icon: Users,
|
icon: Truck,
|
||||||
url: "/vendors",
|
url: "/vendors",
|
||||||
permission: "access:vendors"
|
permission: "access:vendors"
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,5 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { SortAsc, SortDesc } from "lucide-react";
|
import { SortAsc, SortDesc } from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -14,10 +13,11 @@ import {
|
|||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
DragEndEvent,
|
||||||
DragStartEvent,
|
DragStartEvent,
|
||||||
MouseSensor,
|
PointerSensor,
|
||||||
TouchSensor,
|
TouchSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
|
closestCenter,
|
||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
SortableContext,
|
||||||
@@ -26,36 +26,38 @@ import {
|
|||||||
useSortable,
|
useSortable,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Product } from "@/types/products";
|
import { ProductMetric, ProductMetricColumnKey } from "@/types/products";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
export type ColumnKey = keyof Product | 'image';
|
import { getStatusBadge } from "@/utils/productUtils";
|
||||||
|
|
||||||
|
// Column definition
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: ColumnKey;
|
key: ProductMetricColumnKey;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
format?: (value: any) => string | number;
|
|
||||||
width?: string;
|
|
||||||
noLabel?: boolean;
|
noLabel?: boolean;
|
||||||
|
width?: string;
|
||||||
|
format?: (value: any, product?: ProductMetric) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProductTableProps {
|
interface ProductTableProps {
|
||||||
products: Product[];
|
products: ProductMetric[];
|
||||||
onSort: (column: ColumnKey) => void;
|
onSort: (column: ProductMetricColumnKey) => void;
|
||||||
sortColumn: ColumnKey;
|
sortColumn: ProductMetricColumnKey;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
visibleColumns: Set<ColumnKey>;
|
visibleColumns: Set<ProductMetricColumnKey>;
|
||||||
columnDefs: ColumnDef[];
|
columnDefs: ColumnDef[];
|
||||||
columnOrder: ColumnKey[];
|
columnOrder: ProductMetricColumnKey[];
|
||||||
onColumnOrderChange?: (columns: ColumnKey[]) => void;
|
onColumnOrderChange?: (columns: ProductMetricColumnKey[]) => void;
|
||||||
onRowClick?: (product: Product) => void;
|
onRowClick?: (product: ProductMetric) => void;
|
||||||
|
isLoading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SortableHeaderProps {
|
interface SortableHeaderProps {
|
||||||
column: ColumnKey;
|
column: ProductMetricColumnKey;
|
||||||
columnDef?: ColumnDef;
|
columnDef?: ColumnDef;
|
||||||
onSort: (column: ColumnKey) => void;
|
onSort: (column: ProductMetricColumnKey) => void;
|
||||||
sortColumn: ColumnKey;
|
sortColumn: ProductMetricColumnKey;
|
||||||
sortDirection: 'asc' | 'desc';
|
sortDirection: 'asc' | 'desc';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +75,22 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
|||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
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) {
|
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 (
|
return (
|
||||||
@@ -84,7 +98,7 @@ function SortableHeader({ column, columnDef, onSort, sortColumn, sortDirection }
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={cn(
|
className={cn(
|
||||||
"cursor-pointer select-none",
|
"cursor-pointer select-none group",
|
||||||
columnDef?.width,
|
columnDef?.width,
|
||||||
sortColumn === column && "bg-accent/50"
|
sortColumn === column && "bg-accent/50"
|
||||||
)}
|
)}
|
||||||
@@ -114,196 +128,103 @@ export function ProductTable({
|
|||||||
columnOrder = columnDefs.map(col => col.key),
|
columnOrder = columnDefs.map(col => col.key),
|
||||||
onColumnOrderChange,
|
onColumnOrderChange,
|
||||||
onRowClick,
|
onRowClick,
|
||||||
|
isLoading = false,
|
||||||
}: ProductTableProps) {
|
}: ProductTableProps) {
|
||||||
const [, setActiveId] = React.useState<ColumnKey | null>(null);
|
const [activeId, setActiveId] = React.useState<ProductMetricColumnKey | null>(null);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(MouseSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: { distance: 5 },
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
useSensor(TouchSensor, {
|
useSensor(TouchSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: { delay: 250, tolerance: 5 },
|
||||||
delay: 200,
|
|
||||||
tolerance: 8,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get ordered visible columns
|
// Filter columnOrder to only include visible columns for SortableContext
|
||||||
const orderedColumns = React.useMemo(() => {
|
const orderedVisibleColumns = React.useMemo(() => {
|
||||||
return columnOrder.filter(col => visibleColumns.has(col));
|
return columnOrder.filter(col => visibleColumns.has(col));
|
||||||
}, [columnOrder, visibleColumns]);
|
}, [columnOrder, visibleColumns]);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
setActiveId(event.active.id as ColumnKey);
|
setActiveId(event.active.id as ProductMetricColumnKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
const handleDragEnd = (event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
|
|
||||||
if (over && active.id !== over.id) {
|
if (over && active.id !== over.id && onColumnOrderChange) {
|
||||||
const oldIndex = orderedColumns.indexOf(active.id as ColumnKey);
|
const oldIndex = orderedVisibleColumns.indexOf(active.id as ProductMetricColumnKey);
|
||||||
const newIndex = orderedColumns.indexOf(over.id as ColumnKey);
|
const newIndex = orderedVisibleColumns.indexOf(over.id as ProductMetricColumnKey);
|
||||||
|
|
||||||
const newOrder = arrayMove(orderedColumns, oldIndex, newIndex);
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
onColumnOrderChange?.(newOrder);
|
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 (columnKey === 'status') {
|
||||||
if (!status) return null;
|
return <div dangerouslySetInnerHTML={{ __html: getStatusBadge(product.status || 'Unknown') }} />;
|
||||||
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>;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const getABCClass = (abcClass: string | undefined) => {
|
if (columnDef?.format) {
|
||||||
if (!abcClass) return null;
|
return columnDef.format(value, product);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const getLeadTimeStatus = (status: string | undefined) => {
|
// Default formatting for common types if no formatter provided
|
||||||
if (!status) return null;
|
if (typeof value === 'boolean') {
|
||||||
switch (status.toLowerCase()) {
|
return value ? 'Yes' : 'No';
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const formatColumnValue = (product: Product, column: ColumnKey) => {
|
// Handle date strings consistently
|
||||||
const columnDef = columnDefs.find(def => def.key === column);
|
if (value && typeof value === 'string' &&
|
||||||
let value: any = product[column as keyof Product];
|
(columnKey.toLowerCase().includes('date') || columnKey === 'replenishDate')) {
|
||||||
|
try {
|
||||||
switch (column) {
|
return new Date(value).toLocaleDateString();
|
||||||
case 'image':
|
} catch (e) {
|
||||||
return product.image ? (
|
return String(value);
|
||||||
<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 ?? '-';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (value === null || value === undefined || value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to string conversion
|
||||||
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
onDragCancel={() => setActiveId(null)}
|
||||||
>
|
>
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border overflow-x-auto relative">
|
||||||
<Table>
|
{isLoading && (
|
||||||
<TableHeader>
|
<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>
|
<TableRow>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={orderedColumns}
|
items={orderedVisibleColumns}
|
||||||
strategy={horizontalListSortingStrategy}
|
strategy={horizontalListSortingStrategy}
|
||||||
>
|
>
|
||||||
{orderedColumns.map((column) => (
|
{orderedVisibleColumns.map((columnKey) => (
|
||||||
<SortableHeader
|
<SortableHeader
|
||||||
key={column}
|
key={columnKey}
|
||||||
column={column}
|
column={columnKey}
|
||||||
columnDef={columnDefs.find(def => def.key === column)}
|
columnDef={columnDefs.find(def => def.key === columnKey)}
|
||||||
onSort={onSort}
|
onSort={onSort}
|
||||||
sortColumn={sortColumn}
|
sortColumn={sortColumn}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
@@ -313,29 +234,55 @@ export function ProductTable({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{products.map((product) => (
|
{products.length === 0 && !isLoading ? (
|
||||||
<TableRow
|
<TableRow>
|
||||||
key={product.pid}
|
<TableCell
|
||||||
onClick={() => onRowClick?.(product)}
|
colSpan={orderedVisibleColumns.length}
|
||||||
className="cursor-pointer"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{orderedColumns.map((column) => (
|
No products found matching your criteria.
|
||||||
<TableCell key={`${product.pid}-${column}`}>
|
</TableCell>
|
||||||
{formatColumnValue(product, column)}
|
</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>
|
</TableCell>
|
||||||
))}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
{!products.length && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
colSpan={orderedColumns.length}
|
|
||||||
className="text-center py-8 text-muted-foreground"
|
|
||||||
>
|
|
||||||
No products found
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
239
inventory/src/components/products/Products.tsx
Normal file
239
inventory/src/components/products/Products.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
397
inventory/src/pages/Brands.tsx
Normal file
397
inventory/src/pages/Brands.tsx
Normal 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;
|
||||||
@@ -1,428 +1,474 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
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 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 {
|
// Keep your existing type labels or fetch dynamically if preferred
|
||||||
cat_id: number;
|
const TYPE_LABELS: Record<number, string> = {
|
||||||
name: string;
|
10: 'Section', 11: 'Category', 12: 'Subcategory', 13: 'Sub-subcategory',
|
||||||
type: number;
|
1: 'Company', 2: 'Line', 3: 'Subline', 40: 'Artist',
|
||||||
parent_id: number | null;
|
// Add other types as needed
|
||||||
parent_name: string | null;
|
};
|
||||||
parent_type: number | null;
|
|
||||||
description: string | null;
|
// Matches backend COLUMN_MAP keys for sorting
|
||||||
status: string;
|
type CategorySortableColumns =
|
||||||
metrics?: {
|
| '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;
|
product_count: number;
|
||||||
active_products: number;
|
active_product_count: number;
|
||||||
total_value: number;
|
replenishable_product_count: number;
|
||||||
avg_margin: number;
|
current_stock_units: number;
|
||||||
turnover_rate: number;
|
current_stock_cost: string | number;
|
||||||
growth_rate: 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 {
|
// Define response type to avoid type errors
|
||||||
type: number;
|
interface CategoryResponse {
|
||||||
count: number;
|
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 {
|
interface CategoryFilters {
|
||||||
search: string;
|
search: string;
|
||||||
type: string;
|
type: string; // Store type value as string for 'all' option
|
||||||
performance: string;
|
showInactive: boolean; // New filter for showing categories with 0 active products
|
||||||
}
|
}
|
||||||
|
|
||||||
const TYPE_LABELS: Record<number, string> = {
|
const ITEMS_PER_PAGE = 50; // Consistent with backend default
|
||||||
10: 'Section',
|
|
||||||
11: 'Category',
|
// Helper for formatting
|
||||||
12: 'Subcategory',
|
const formatCurrency = (value: number | string | null | undefined, digits = 0): string => {
|
||||||
13: 'Sub-subcategory',
|
if (value == null) return 'N/A';
|
||||||
20: 'Theme',
|
if (typeof value === 'string') {
|
||||||
21: 'Subtheme'
|
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() {
|
export function Categories() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortColumn] = useState<keyof Category>("name");
|
const [limit] = useState(ITEMS_PER_PAGE);
|
||||||
const [sortDirection] = useState<"asc" | "desc">("asc");
|
const [sortColumn, setSortColumn] = useState<CategorySortableColumns>("categoryName");
|
||||||
const [filters, setFilters] = useState<CategoryFilters>({
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
search: "",
|
const [filters, setFilters] = useState<CategoryFilters>({
|
||||||
type: "all",
|
search: "",
|
||||||
performance: "all",
|
type: "all",
|
||||||
});
|
showInactive: false, // Default to hiding categories with 0 active products
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
// --- Data Fetching ---
|
||||||
}, [data?.categories, filters, sortColumn, sortDirection]);
|
|
||||||
|
|
||||||
// Calculate pagination
|
const queryParams = useMemo(() => {
|
||||||
const totalPages = Math.ceil(filteredData.length / 50);
|
const params = new URLSearchParams();
|
||||||
const paginatedData = useMemo(() => {
|
params.set('page', page.toString());
|
||||||
const start = (page - 1) * 50;
|
params.set('limit', limit.toString());
|
||||||
const end = start + 50;
|
params.set('sort', sortColumn);
|
||||||
return filteredData.slice(start, end);
|
params.set('order', sortDirection);
|
||||||
}, [filteredData, page]);
|
|
||||||
|
|
||||||
// Calculate stats from filtered data
|
if (filters.search) {
|
||||||
const stats = useMemo(() => {
|
params.set('categoryName_ilike', filters.search); // Use ILIKE for case-insensitive search
|
||||||
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.type !== 'all') {
|
||||||
className="container mx-auto py-6 space-y-4"
|
params.set('categoryType_eq', filters.type); // Use exact match for type
|
||||||
>
|
}
|
||||||
<motion.div
|
if (!filters.showInactive) {
|
||||||
layout="position"
|
params.set('activeProductCount_gt', '0'); // Only show categories with active products
|
||||||
transition={{ duration: 0.15 }}
|
}
|
||||||
className="flex items-center justify-between"
|
// Add more filters here if needed, mapping to backend keys (e.g., revenue30d_gt=1000)
|
||||||
>
|
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
{filteredData.length.toLocaleString()} categories
|
|
||||||
</div>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<motion.div
|
return params;
|
||||||
layout="preserve-aspect"
|
}, [page, limit, sortColumn, sortDirection, filters]);
|
||||||
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>
|
|
||||||
|
|
||||||
<Card>
|
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<CategoryResponse, Error>({
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
queryKey: ['categories', queryParams.toString()], // Use stringified params as key part
|
||||||
<CardTitle className="text-sm font-medium">Total Value</CardTitle>
|
queryFn: async () => {
|
||||||
</CardHeader>
|
const response = await fetch(`${config.apiUrl}/categories-aggregate?${queryParams.toString()}`, {
|
||||||
<CardContent>
|
credentials: 'include'
|
||||||
<div className="text-2xl font-bold">{data?.stats?.totalValue ? formatCurrency(data.stats.totalValue) : "..."}</div>
|
});
|
||||||
<p className="text-xs text-muted-foreground">
|
if (!response.ok) {
|
||||||
Inventory value
|
throw new Error(`Network response was not ok (${response.status})`);
|
||||||
</p>
|
}
|
||||||
</CardContent>
|
const data = await response.json();
|
||||||
</Card>
|
console.log('Categories data:', JSON.stringify(data, null, 2));
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
|
||||||
|
});
|
||||||
|
|
||||||
<Card>
|
const { data: statsData, isLoading: isLoadingStats } = useQuery<CategoryStats, Error>({
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
queryKey: ['categoriesStats'],
|
||||||
<CardTitle className="text-sm font-medium">Avg Margin</CardTitle>
|
queryFn: async () => {
|
||||||
</CardHeader>
|
const response = await fetch(`${config.apiUrl}/categories-aggregate/stats`, {
|
||||||
<CardContent>
|
credentials: 'include'
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgMargin === 'number' ? data.stats.avgMargin.toFixed(1) : "..."}%</div>
|
});
|
||||||
<p className="text-xs text-muted-foreground">
|
if (!response.ok) throw new Error("Failed to fetch category stats");
|
||||||
Across all categories
|
return response.json();
|
||||||
</p>
|
},
|
||||||
</CardContent>
|
});
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<CategoryFilterOptions, Error>({
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
queryKey: ['categoriesFilterOptions'],
|
||||||
<CardTitle className="text-sm font-medium">Avg Growth</CardTitle>
|
queryFn: async () => {
|
||||||
</CardHeader>
|
const response = await fetch(`${config.apiUrl}/categories-aggregate/filter-options`, {
|
||||||
<CardContent>
|
credentials: 'include'
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgGrowth === 'number' ? data.stats.avgGrowth.toFixed(1) : "..."}%</div>
|
});
|
||||||
<p className="text-xs text-muted-foreground">
|
if (!response.ok) throw new Error("Failed to fetch category filter options");
|
||||||
Year over year
|
return response.json();
|
||||||
</p>
|
},
|
||||||
</CardContent>
|
});
|
||||||
</Card>
|
|
||||||
</motion.div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
// --- Event Handlers ---
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="rounded-md border">
|
const handleSort = useCallback((column: CategorySortableColumns) => {
|
||||||
<Table>
|
setSortDirection(prev => {
|
||||||
<TableHeader>
|
if (sortColumn !== column) return "asc";
|
||||||
<TableRow>
|
return prev === "asc" ? "desc" : "asc";
|
||||||
<TableHead>Type</TableHead>
|
});
|
||||||
<TableHead>Name</TableHead>
|
setSortColumn(column);
|
||||||
<TableHead>Parent</TableHead>
|
setPage(1); // Reset to first page on sort change
|
||||||
<TableHead className="text-right">Products</TableHead>
|
}, [sortColumn]);
|
||||||
<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>
|
|
||||||
|
|
||||||
</div>
|
const handleFilterChange = useCallback((filterName: keyof CategoryFilters, value: string | boolean) => {
|
||||||
{category.description && (
|
setFilters(prev => ({ ...prev, [filterName]: value }));
|
||||||
<div className="text-xs text-muted-foreground">{category.description}</div>
|
setPage(1); // Reset to first page on filter change
|
||||||
)}
|
}, []);
|
||||||
</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>
|
|
||||||
|
|
||||||
{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
|
<motion.div
|
||||||
layout="position"
|
layout
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
|
||||||
className="flex justify-center"
|
className="container mx-auto py-6 space-y-4"
|
||||||
>
|
>
|
||||||
<Pagination>
|
{/* Header */}
|
||||||
<PaginationContent>
|
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
|
||||||
<PaginationItem>
|
<h1 className="text-3xl font-bold tracking-tight">Categories</h1>
|
||||||
<PaginationPrevious
|
<div className="text-sm text-muted-foreground">
|
||||||
href="#"
|
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} categories`}
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.preventDefault();
|
</motion.div>
|
||||||
if (page > 1) setPage(p => p - 1);
|
|
||||||
}}
|
{/* Stats Cards */}
|
||||||
aria-disabled={page === 1}
|
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
|
||||||
/>
|
<Card>
|
||||||
</PaginationItem>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
{Array.from({ length: totalPages }, (_, i) => (
|
<CardTitle className="text-sm font-medium">Total Categories</CardTitle>
|
||||||
<PaginationItem key={i + 1}>
|
</CardHeader>
|
||||||
<PaginationLink
|
<CardContent>
|
||||||
href="#"
|
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalCategories)}</div>}
|
||||||
onClick={(e) => {
|
<p className="text-xs text-muted-foreground">
|
||||||
e.preventDefault();
|
{isLoadingStats ? <Skeleton className="h-4 w-16 mt-1" /> : `${formatNumber(statsData?.activeCategories)} active`}
|
||||||
setPage(i + 1);
|
</p>
|
||||||
}}
|
</CardContent>
|
||||||
isActive={page === i + 1}
|
</Card>
|
||||||
>
|
<Card>
|
||||||
{i + 1}
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</PaginationLink>
|
<CardTitle className="text-sm font-medium">Total Stock Value</CardTitle>
|
||||||
</PaginationItem>
|
</CardHeader>
|
||||||
))}
|
<CardContent>
|
||||||
<PaginationItem>
|
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
|
||||||
<PaginationNext
|
<p className="text-xs text-muted-foreground">
|
||||||
href="#"
|
Current cost value
|
||||||
onClick={(e) => {
|
</p>
|
||||||
e.preventDefault();
|
</CardContent>
|
||||||
if (page < totalPages) setPage(p => p + 1);
|
</Card>
|
||||||
}}
|
<Card>
|
||||||
aria-disabled={page >= totalPages}
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
/>
|
<CardTitle className="text-sm font-medium">Avg Margin (30d)</CardTitle>
|
||||||
</PaginationItem>
|
</CardHeader>
|
||||||
</PaginationContent>
|
<CardContent>
|
||||||
</Pagination>
|
{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>
|
||||||
)}
|
);
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Categories;
|
export default Categories;
|
||||||
@@ -8,8 +8,8 @@ import { ProductTableSkeleton } from '@/components/products/ProductTableSkeleton
|
|||||||
import { ProductDetail } from '@/components/products/ProductDetail';
|
import { ProductDetail } from '@/components/products/ProductDetail';
|
||||||
import { ProductViews } from '@/components/products/ProductViews';
|
import { ProductViews } from '@/components/products/ProductViews';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Product } from '@/types/products';
|
import { Product, ProductMetric, ProductMetricColumnKey } from '@/types/products';
|
||||||
import type { ColumnKey } from '@/components/products/ProductTable';
|
import { getProductStatus } from '@/utils/productUtils';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuCheckboxItem,
|
DropdownMenuCheckboxItem,
|
||||||
@@ -35,7 +35,7 @@ import { toast } from "sonner";
|
|||||||
|
|
||||||
// Column definition type
|
// Column definition type
|
||||||
interface ColumnDef {
|
interface ColumnDef {
|
||||||
key: ColumnKey;
|
key: ProductMetricColumnKey;
|
||||||
label: string;
|
label: string;
|
||||||
group: string;
|
group: string;
|
||||||
noLabel?: boolean;
|
noLabel?: boolean;
|
||||||
@@ -45,171 +45,162 @@ interface ColumnDef {
|
|||||||
|
|
||||||
// Define available columns with their groups
|
// Define available columns with their groups
|
||||||
const AVAILABLE_COLUMNS: ColumnDef[] = [
|
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: '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: 'brand', label: 'Company', group: 'Basic Info' },
|
||||||
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
|
{ key: 'vendor', label: 'Supplier', group: 'Basic Info' },
|
||||||
{ key: 'vendor_reference', label: 'Supplier #', group: 'Basic Info' },
|
{ key: 'isVisible', label: 'Visible', group: 'Basic Info' },
|
||||||
{ key: 'barcode', label: 'UPC', group: 'Basic Info' },
|
{ key: 'isReplenishable', label: 'Replenishable', group: 'Basic Info' },
|
||||||
{ key: 'description', label: 'Description', group: 'Basic Info' },
|
{ key: 'dateCreated', label: 'Created', 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' },
|
|
||||||
|
|
||||||
// Physical properties
|
// Current Status
|
||||||
{ key: 'weight', label: 'Weight', group: 'Physical', format: (v) => v?.toString() ?? '-' },
|
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'dimensions', label: 'Dimensions', group: 'Physical', format: (v) => v ? `${v.length}x${v.width}x${v.height}` : '-' },
|
{ 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
|
// Dates
|
||||||
{ key: 'stock_quantity', label: 'Shelf Count', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
{ key: 'dateFirstReceived', label: 'First Received', group: 'Dates' },
|
||||||
{ key: 'stock_status', label: 'Stock Status', group: 'Stock' },
|
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
|
||||||
{ key: 'preorder_count', label: 'Preorders', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
|
||||||
{ key: 'notions_inv_count', label: 'Notions Inv', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
|
||||||
{ key: 'days_of_inventory', label: 'Days of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v?.toString() ?? '-' },
|
||||||
{ 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() ?? '-' },
|
|
||||||
|
|
||||||
// Pricing columns
|
// Product Status
|
||||||
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'status', label: 'Status', group: 'Status' },
|
||||||
{ 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) ?? '-' },
|
|
||||||
|
|
||||||
// Sales columns
|
// Rolling Metrics
|
||||||
{ key: 'daily_sales_avg', label: 'Daily Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'weekly_sales_avg', label: 'Weekly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'monthly_sales_avg', label: 'Monthly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'avg_quantity_per_order', label: 'Avg Qty/Order', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'number_of_orders', label: 'Order Count', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales' },
|
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales' },
|
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'date_last_sold', label: 'Date Last Sold', group: 'Sales' },
|
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'total_sold', label: 'Total Sold', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'baskets', label: 'In Baskets', group: 'Sales', format: (v) => v?.toString() ?? '-' },
|
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ 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() ?? '-' },
|
|
||||||
|
|
||||||
// Financial columns
|
// KPIs
|
||||||
{ key: 'gmroi', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'avg_margin_percent', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'inventory_value', label: 'Inventory Value', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
||||||
{ key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
|
||||||
{ key: 'gross_profit', label: 'Gross Profit', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
|
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
|
|
||||||
// Lead Time columns
|
// Replenishment
|
||||||
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' },
|
||||||
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
|
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Lead Time' },
|
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Lead Time' },
|
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
|
||||||
{ key: 'first_received_date', label: 'First Received', group: 'Lead Time' },
|
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
|
||||||
{ key: 'last_received_date', label: 'Last Received', group: 'Lead Time' },
|
{ 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
|
// Define default columns for each view
|
||||||
const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
|
const VIEW_COLUMNS: Record<string, ProductMetricColumnKey[]> = {
|
||||||
all: [
|
all: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'brand',
|
'brand',
|
||||||
'vendor',
|
'vendor',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'stock_status',
|
'status',
|
||||||
'reorder_qty',
|
'salesVelocityDaily',
|
||||||
'price',
|
'currentPrice',
|
||||||
'regular_price',
|
'currentRegularPrice',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'monthly_sales_avg',
|
'revenue30d',
|
||||||
'inventory_value',
|
'currentStockCost',
|
||||||
],
|
],
|
||||||
critical: [
|
critical: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'safety_stock',
|
'configSafetyStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'reorder_qty',
|
'replenishmentUnits',
|
||||||
'reorder_point',
|
'salesVelocityDaily',
|
||||||
'vendor',
|
'vendor',
|
||||||
'last_purchase_date',
|
'dateLastReceived',
|
||||||
'current_lead_time',
|
'avgLeadTimeDays',
|
||||||
],
|
],
|
||||||
reorder: [
|
reorder: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'reorder_point',
|
'salesVelocityDaily',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'reorder_qty',
|
'replenishmentUnits',
|
||||||
'vendor',
|
'vendor',
|
||||||
'last_purchase_date',
|
'dateLastReceived',
|
||||||
'avg_lead_time_days',
|
'avgLeadTimeDays',
|
||||||
],
|
],
|
||||||
overstocked: [
|
overstocked: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'overstocked_amt',
|
'overstockedUnits',
|
||||||
'days_of_inventory',
|
'stockCoverInDays',
|
||||||
'inventory_value',
|
'currentStockCost',
|
||||||
'turnover_rate',
|
'stockturn30d',
|
||||||
],
|
],
|
||||||
'at-risk': [
|
'at-risk': [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'safety_stock',
|
'configSafetyStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'days_of_inventory',
|
'stockCoverInDays',
|
||||||
'last_sale_date',
|
'dateLastSold',
|
||||||
'current_lead_time',
|
'avgLeadTimeDays',
|
||||||
],
|
],
|
||||||
new: [
|
new: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'vendor',
|
'vendor',
|
||||||
'brand',
|
'brand',
|
||||||
'price',
|
'currentPrice',
|
||||||
'regular_price',
|
'currentRegularPrice',
|
||||||
'first_received_date',
|
'dateFirstReceived',
|
||||||
],
|
],
|
||||||
healthy: [
|
healthy: [
|
||||||
'image',
|
'imageUrl',
|
||||||
'title',
|
'title',
|
||||||
'stock_quantity',
|
'currentStock',
|
||||||
'daily_sales_avg',
|
'sales7d',
|
||||||
'weekly_sales_avg',
|
'sales30d',
|
||||||
'monthly_sales_avg',
|
'revenue30d',
|
||||||
'days_of_inventory',
|
'stockCoverInDays',
|
||||||
'gross_profit',
|
'profit30d',
|
||||||
'gmroi',
|
'gmroi30d',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Products() {
|
export function Products() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
|
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 [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
|
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
|
||||||
@@ -219,16 +210,16 @@ export function Products() {
|
|||||||
const [, setIsLoading] = useState(false);
|
const [, setIsLoading] = useState(false);
|
||||||
|
|
||||||
// Store visible columns and order for each view
|
// Store visible columns and order for each view
|
||||||
const [viewColumns, setViewColumns] = useState<Record<string, Set<ColumnKey>>>(() => {
|
const [viewColumns, setViewColumns] = useState<Record<string, Set<ProductMetricColumnKey>>>(() => {
|
||||||
const initialColumns: Record<string, Set<ColumnKey>> = {};
|
const initialColumns: Record<string, Set<ProductMetricColumnKey>> = {};
|
||||||
Object.entries(VIEW_COLUMNS).forEach(([view, columns]) => {
|
Object.entries(VIEW_COLUMNS).forEach(([view, columns]) => {
|
||||||
initialColumns[view] = new Set(columns);
|
initialColumns[view] = new Set(columns);
|
||||||
});
|
});
|
||||||
return initialColumns;
|
return initialColumns;
|
||||||
});
|
});
|
||||||
|
|
||||||
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ColumnKey[]>>(() => {
|
const [viewColumnOrder, setViewColumnOrder] = useState<Record<string, ProductMetricColumnKey[]>>(() => {
|
||||||
const initialOrder: Record<string, ColumnKey[]> = {};
|
const initialOrder: Record<string, ProductMetricColumnKey[]> = {};
|
||||||
Object.entries(VIEW_COLUMNS).forEach(([view, defaultColumns]) => {
|
Object.entries(VIEW_COLUMNS).forEach(([view, defaultColumns]) => {
|
||||||
initialOrder[view] = [
|
initialOrder[view] = [
|
||||||
...defaultColumns,
|
...defaultColumns,
|
||||||
@@ -241,16 +232,19 @@ export function Products() {
|
|||||||
// Get current view's columns
|
// Get current view's columns
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
|
const columns = new Set(viewColumns[activeView] || VIEW_COLUMNS.all);
|
||||||
|
|
||||||
|
// Add isReplenishable column when showing non-replenishable products for better visibility
|
||||||
if (showNonReplenishable) {
|
if (showNonReplenishable) {
|
||||||
columns.add('replenishable');
|
columns.add('isReplenishable');
|
||||||
}
|
}
|
||||||
|
|
||||||
return columns;
|
return columns;
|
||||||
}, [viewColumns, activeView, showNonReplenishable]);
|
}, [viewColumns, activeView, showNonReplenishable]);
|
||||||
|
|
||||||
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
|
const columnOrder = viewColumnOrder[activeView] || viewColumnOrder.all;
|
||||||
|
|
||||||
// Handle column visibility changes
|
// Handle column visibility changes
|
||||||
const handleColumnVisibilityChange = (column: ColumnKey, isVisible: boolean) => {
|
const handleColumnVisibilityChange = (column: ProductMetricColumnKey, isVisible: boolean) => {
|
||||||
setViewColumns(prev => ({
|
setViewColumns(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[activeView]: isVisible
|
[activeView]: isVisible
|
||||||
@@ -260,7 +254,7 @@ export function Products() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Handle column order changes
|
// Handle column order changes
|
||||||
const handleColumnOrderChange = (newOrder: ColumnKey[]) => {
|
const handleColumnOrderChange = (newOrder: ProductMetricColumnKey[]) => {
|
||||||
setViewColumnOrder(prev => ({
|
setViewColumnOrder(prev => ({
|
||||||
...prev,
|
...prev,
|
||||||
[activeView]: newOrder
|
[activeView]: newOrder
|
||||||
@@ -307,35 +301,93 @@ export function Products() {
|
|||||||
params.append('limit', pageSize.toString());
|
params.append('limit', pageSize.toString());
|
||||||
|
|
||||||
if (sortColumn) {
|
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);
|
params.append('order', sortDirection);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (activeView && activeView !== 'all') {
|
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
|
// Transform filters to match API expectations
|
||||||
const transformedFilters = transformFilters(filters);
|
const transformedFilters = transformFilters(filters);
|
||||||
Object.entries(transformedFilters).forEach(([key, value]) => {
|
Object.entries(transformedFilters).forEach(([key, value]) => {
|
||||||
if (value !== undefined && value !== null && 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)) {
|
if (Array.isArray(value)) {
|
||||||
params.append(key, JSON.stringify(value));
|
params.append(snakeCaseKey, JSON.stringify(value));
|
||||||
} else {
|
} else {
|
||||||
params.append(key, value.toString());
|
params.append(snakeCaseKey, value.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!showNonReplenishable) {
|
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');
|
if (!response.ok) throw new Error('Failed to fetch products');
|
||||||
|
|
||||||
const data = await response.json();
|
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) {
|
} catch (error) {
|
||||||
console.error('Error fetching products:', error);
|
console.error('Error fetching products:', error);
|
||||||
toast("Failed to fetch products. Please try again.");
|
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
|
// Query for products data
|
||||||
const { data, isFetching } = useQuery({
|
const { data, isFetching } = useQuery({
|
||||||
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable],
|
queryKey: ['products', currentPage, pageSize, sortColumn, sortDirection, activeView, filters, showNonReplenishable],
|
||||||
@@ -360,7 +435,7 @@ export function Products() {
|
|||||||
}, [currentPage, data?.pagination.pages]);
|
}, [currentPage, data?.pagination.pages]);
|
||||||
|
|
||||||
// Handle sort column change
|
// Handle sort column change
|
||||||
const handleSort = (column: keyof Product) => {
|
const handleSort = (column: ProductMetricColumnKey) => {
|
||||||
setSortDirection(prev => {
|
setSortDirection(prev => {
|
||||||
if (sortColumn !== column) return 'asc';
|
if (sortColumn !== column) return 'asc';
|
||||||
return prev === 'asc' ? 'desc' : 'asc';
|
return prev === 'asc' ? 'desc' : 'asc';
|
||||||
@@ -515,9 +590,12 @@ export function Products() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<ProductFilters
|
<ProductFilters
|
||||||
categories={data?.filters?.categories ?? []}
|
filterOptions={{
|
||||||
vendors={data?.filters?.vendors ?? []}
|
vendors: filterOptionsData?.vendors ?? [],
|
||||||
brands={data?.filters?.brands ?? []}
|
brands: filterOptionsData?.brands ?? [],
|
||||||
|
abcClasses: filterOptionsData?.abcClasses ?? []
|
||||||
|
}}
|
||||||
|
isLoadingOptions={isLoadingFilterOptions}
|
||||||
onFilterChange={handleFilterChange}
|
onFilterChange={handleFilterChange}
|
||||||
onClearFilters={handleClearFilters}
|
onClearFilters={handleClearFilters}
|
||||||
activeFilters={filters}
|
activeFilters={filters}
|
||||||
@@ -534,7 +612,7 @@ export function Products() {
|
|||||||
/>
|
/>
|
||||||
<Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label>
|
<Label htmlFor="show-non-replenishable">Show Non-Replenishable</Label>
|
||||||
</div>
|
</div>
|
||||||
{data?.pagination.total > 0 && (
|
{data?.pagination?.total !== undefined && (
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
{data.pagination.total.toLocaleString()} products
|
{data.pagination.total.toLocaleString()} products
|
||||||
</div>
|
</div>
|
||||||
@@ -548,7 +626,13 @@ export function Products() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<ProductTable
|
<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}
|
onSort={handleSort}
|
||||||
sortColumn={sortColumn}
|
sortColumn={sortColumn}
|
||||||
sortDirection={sortDirection}
|
sortDirection={sortDirection}
|
||||||
|
|||||||
@@ -1,350 +1,430 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo, useCallback } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Badge } from "@/components/ui/badge";
|
// import { Badge } from "@/components/ui/badge"; // Badge removed as status/performance filters are gone
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
// 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 { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
|
||||||
import { motion } from "framer-motion";
|
import { motion } from "framer-motion";
|
||||||
import config from "../config";
|
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 {
|
// Matches backend COLUMN_MAP keys for sorting
|
||||||
vendor_id: number;
|
type VendorSortableColumns =
|
||||||
name: string;
|
| 'vendorName' | 'productCount' | 'activeProductCount' | 'currentStockUnits'
|
||||||
status: string;
|
| 'currentStockCost' | 'onOrderUnits' | 'onOrderCost' | 'avgLeadTimeDays'
|
||||||
avg_lead_time_days: number;
|
| 'revenue_30d' | 'profit_30d' | 'avg_margin_30d' | 'po_count_365d';
|
||||||
on_time_delivery_rate: number;
|
|
||||||
order_fill_rate: number;
|
interface VendorMetric {
|
||||||
total_orders: number;
|
// Assuming vendor_name is unique primary identifier
|
||||||
active_products: number;
|
vendor_name: string;
|
||||||
avg_unit_cost: number;
|
last_calculated: string;
|
||||||
total_spend: number;
|
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 {
|
interface VendorFilters {
|
||||||
search: string;
|
search: string;
|
||||||
status: string;
|
showInactive: boolean; // New filter for showing vendors with 0 active products
|
||||||
performance: string;
|
// Status and Performance filters removed
|
||||||
}
|
}
|
||||||
|
|
||||||
const ITEMS_PER_PAGE = 50;
|
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() {
|
export function Vendors() {
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [sortColumn, setSortColumn] = useState<keyof Vendor>("name");
|
const [limit] = useState(ITEMS_PER_PAGE);
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
const [sortColumn, setSortColumn] = useState<VendorSortableColumns>("vendorName");
|
||||||
const [filters, setFilters] = useState<VendorFilters>({
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
search: "",
|
const [filters, setFilters] = useState<VendorFilters>({
|
||||||
status: "all",
|
search: "",
|
||||||
performance: "all",
|
showInactive: false, // Default to hiding vendors with 0 active products
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return filtered;
|
// --- Data Fetching ---
|
||||||
}, [data?.vendors, filters, sortColumn, sortDirection]);
|
|
||||||
|
|
||||||
// Calculate pagination
|
const queryParams = useMemo(() => {
|
||||||
const totalPages = Math.ceil(filteredData.length / ITEMS_PER_PAGE);
|
const params = new URLSearchParams();
|
||||||
const paginatedData = useMemo(() => {
|
params.set('page', page.toString());
|
||||||
const start = (page - 1) * ITEMS_PER_PAGE;
|
params.set('limit', limit.toString());
|
||||||
const end = start + ITEMS_PER_PAGE;
|
params.set('sort', sortColumn);
|
||||||
return filteredData.slice(start, end);
|
params.set('order', sortDirection);
|
||||||
}, [filteredData, page]);
|
|
||||||
|
|
||||||
const handleSort = (column: keyof Vendor) => {
|
if (filters.search) {
|
||||||
setSortDirection(prev => {
|
params.set('vendorName_ilike', filters.search); // Filter by name
|
||||||
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.showInactive) {
|
||||||
className="container mx-auto py-6 space-y-4"
|
params.set('activeProductCount_gt', '0'); // Only show vendors with active products
|
||||||
>
|
}
|
||||||
<motion.div
|
// Add more filters here if needed (e.g., avgLeadTimeDays_lte=10)
|
||||||
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>
|
|
||||||
|
|
||||||
<motion.div
|
return params;
|
||||||
layout="preserve-aspect"
|
}, [page, limit, sortColumn, sortDirection, filters]);
|
||||||
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>
|
|
||||||
|
|
||||||
<Card>
|
const { data: listData, isLoading: isLoadingList, error: listError } = useQuery<VendorResponse, Error>({
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
queryKey: ['vendors', queryParams.toString()],
|
||||||
<CardTitle className="text-sm font-medium">Total Spend</CardTitle>
|
queryFn: async () => {
|
||||||
</CardHeader>
|
const response = await fetch(`${config.apiUrl}/vendors-aggregate?${queryParams.toString()}`, {
|
||||||
<CardContent>
|
credentials: 'include'
|
||||||
<div className="text-2xl font-bold">
|
});
|
||||||
${typeof data?.stats?.totalSpend === 'number' ? data.stats.totalSpend.toLocaleString(undefined, { minimumFractionDigits: 0, maximumFractionDigits: 0 }) : "..."}
|
if (!response.ok) throw new Error(`Network response was not ok (${response.status})`);
|
||||||
</div>
|
const data = await response.json();
|
||||||
<p className="text-xs text-muted-foreground">
|
console.log('Vendors data:', JSON.stringify(data, null, 2));
|
||||||
Avg unit cost: ${typeof data?.stats?.avgUnitCost === 'number' ? data.stats.avgUnitCost.toFixed(2) : "..."}
|
return data;
|
||||||
</p>
|
},
|
||||||
</CardContent>
|
placeholderData: (prev) => prev, // Modern replacement for keepPreviousData
|
||||||
</Card>
|
});
|
||||||
|
|
||||||
<Card>
|
const { data: statsData, isLoading: isLoadingStats } = useQuery<VendorStats, Error>({
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
queryKey: ['vendorsStats'],
|
||||||
<CardTitle className="text-sm font-medium">Performance</CardTitle>
|
queryFn: async () => {
|
||||||
</CardHeader>
|
const response = await fetch(`${config.apiUrl}/vendors-aggregate/stats`, {
|
||||||
<CardContent>
|
credentials: 'include'
|
||||||
<div className="text-2xl font-bold">{typeof data?.stats?.avgFillRate === 'number' ? data.stats.avgFillRate.toFixed(1) : "..."}%</div>
|
});
|
||||||
<p className="text-xs text-muted-foreground">
|
if (!response.ok) throw new Error("Failed to fetch vendor stats");
|
||||||
Fill rate / {typeof data?.stats?.avgOnTimeDelivery === 'number' ? data.stats.avgOnTimeDelivery.toFixed(1) : "..."}% on-time
|
return response.json();
|
||||||
</p>
|
},
|
||||||
</CardContent>
|
});
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
// Filter options query might not be needed if only search is used
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
// const { data: filterOptions, isLoading: isLoadingFilterOptions } = useQuery<VendorFilterOptions, Error>({ ... });
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between">
|
// --- Event Handlers ---
|
||||||
<div className="flex flex-1 items-center space-x-2">
|
|
||||||
<Input
|
|
||||||
placeholder="Search vendors..."
|
|
||||||
value={filters.search}
|
|
||||||
onChange={(e) => setFilters(prev => ({ ...prev, search: e.target.value }))}
|
|
||||||
className="h-8 w-[150px] lg:w-[250px]"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={filters.status}
|
|
||||||
onValueChange={(value) => setFilters(prev => ({ ...prev, status: value }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[150px]">
|
|
||||||
<SelectValue placeholder="Status" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
|
||||||
<SelectItem value="active">Active</SelectItem>
|
|
||||||
<SelectItem value="inactive">Inactive</SelectItem>
|
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Select
|
|
||||||
value={filters.performance}
|
|
||||||
onValueChange={(value) => setFilters(prev => ({ ...prev, performance: value }))}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[150px]">
|
|
||||||
<SelectValue placeholder="Performance" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="all">All Performance</SelectItem>
|
|
||||||
<SelectItem value="excellent">Excellent</SelectItem>
|
|
||||||
<SelectItem value="good">Good</SelectItem>
|
|
||||||
<SelectItem value="fair">Fair</SelectItem>
|
|
||||||
<SelectItem value="poor">Poor</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-md border">
|
const handleSort = useCallback((column: VendorSortableColumns) => {
|
||||||
<Table>
|
setSortDirection(prev => (sortColumn === column && prev === "asc" ? "desc" : "asc"));
|
||||||
<TableHeader>
|
setSortColumn(column);
|
||||||
<TableRow>
|
setPage(1);
|
||||||
<TableHead onClick={() => handleSort("name")} className="cursor-pointer">Vendor</TableHead>
|
}, [sortColumn]);
|
||||||
<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>
|
|
||||||
|
|
||||||
{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
|
<motion.div
|
||||||
layout="position"
|
layout
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ layout: { duration: 0.15, ease: [0.4, 0, 0.2, 1] } }}
|
||||||
className="flex justify-center"
|
className="container mx-auto py-6 space-y-4"
|
||||||
>
|
>
|
||||||
<Pagination>
|
{/* Header */}
|
||||||
<PaginationContent>
|
<motion.div layout="position" transition={{ duration: 0.15 }} className="flex items-center justify-between">
|
||||||
<PaginationItem>
|
<h1 className="text-3xl font-bold tracking-tight">Vendors</h1>
|
||||||
<PaginationPrevious
|
<div className="text-sm text-muted-foreground">
|
||||||
href="#"
|
{isLoadingList && !pagination ? 'Loading...' : `${formatNumber(pagination?.total)} vendors`}
|
||||||
onClick={(e) => {
|
</div>
|
||||||
e.preventDefault();
|
</motion.div>
|
||||||
if (page > 1) setPage(p => p - 1);
|
|
||||||
}}
|
{/* Stats Cards */}
|
||||||
aria-disabled={page === 1}
|
<motion.div layout="preserve-aspect" transition={{ duration: 0.15 }} className="grid gap-4 md:grid-cols-4">
|
||||||
/>
|
<Card>
|
||||||
</PaginationItem>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
{Array.from({ length: totalPages }, (_, i) => (
|
<CardTitle className="text-sm font-medium">Total Vendors</CardTitle>
|
||||||
<PaginationItem key={i + 1}>
|
</CardHeader>
|
||||||
<PaginationLink
|
<CardContent>
|
||||||
href="#"
|
{isLoadingStats ? <Skeleton className="h-8 w-24" /> : <div className="text-2xl font-bold">{formatNumber(statsData?.totalVendors)}</div>}
|
||||||
onClick={(e) => {
|
<p className="text-xs text-muted-foreground">
|
||||||
e.preventDefault();
|
{/* Active vendor count not directly available, showing total */}
|
||||||
setPage(i + 1);
|
All vendors with metrics
|
||||||
}}
|
</p>
|
||||||
isActive={page === i + 1}
|
</CardContent>
|
||||||
>
|
</Card>
|
||||||
{i + 1}
|
<Card>
|
||||||
</PaginationLink>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</PaginationItem>
|
<CardTitle className="text-sm font-medium">Total Stock Value</CardTitle>
|
||||||
))}
|
</CardHeader>
|
||||||
<PaginationItem>
|
<CardContent>
|
||||||
<PaginationNext
|
{isLoadingStats ? <Skeleton className="h-8 w-28" /> : <div className="text-2xl font-bold">{formatCurrency(statsData?.totalValue)}</div>}
|
||||||
href="#"
|
<p className="text-xs text-muted-foreground">
|
||||||
onClick={(e) => {
|
Current cost value
|
||||||
e.preventDefault();
|
</p>
|
||||||
if (page < totalPages) setPage(p => p + 1);
|
</CardContent>
|
||||||
}}
|
</Card>
|
||||||
aria-disabled={page >= totalPages}
|
<Card>
|
||||||
/>
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
</PaginationItem>
|
<CardTitle className="text-sm font-medium">Value On Order</CardTitle>
|
||||||
</PaginationContent>
|
</CardHeader>
|
||||||
</Pagination>
|
<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>
|
||||||
)}
|
);
|
||||||
</motion.div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Vendors;
|
export default Vendors;
|
||||||
@@ -78,3 +78,196 @@ export interface Product {
|
|||||||
reorder_qty?: number;
|
reorder_qty?: number;
|
||||||
overstocked_amt?: string; // numeric(15,3)
|
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;
|
||||||
|
}
|
||||||
|
|||||||
134
inventory/src/utils/productUtils.ts
Normal file
134
inventory/src/utils/productUtils.ts
Normal 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';
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user