Move product status calculation to database, fix up products table, more categories tweaks

This commit is contained in:
2025-04-03 17:12:10 -04:00
parent 2601a04211
commit 4552fa4862
9 changed files with 735 additions and 219 deletions

View File

@@ -107,7 +107,7 @@ export function AppSidebar() {
className="w-6 h-6 object-contain -rotate-12 transform hover:rotate-0 transition-transform ease-in-out duration-300"
/>
</div>
<div className="ml-2 transition-all duration-200 whitespace-nowrap group-[.group[data-state=collapsed]]:hidden">
<div className="ml-1 transition-all duration-200 whitespace-nowrap group-[.group[data-state=collapsed]]:hidden">
<span className="font-bold text-lg">A Cherry On Bottom</span>
</div>
</div>

View File

@@ -53,12 +53,14 @@ type CategorySortableColumns =
| "activeProductCount"
| "currentStockUnits"
| "currentStockCost"
| "revenue_7d"
| "revenue_30d"
| "profit_30d"
| "sales_30d"
| "avg_margin_30d"
| "stock_turn_30d";
| "currentStockRetail"
| "revenue7d"
| "revenue30d"
| "profit30d"
| "sales30d"
| "avgMargin30d"
| "stockTurn30d"
| "status";
interface CategoryMetric {
// Assuming category_id is unique primary identifier in category_metrics
@@ -375,17 +377,26 @@ export function Categories() {
if (filters.search) {
params.set("categoryName_ilike", filters.search);
}
if (filters.type !== "all") {
// The backend expects categoryType_eq
// The type is stored as integer in the database
console.log(`Setting categoryType_eq to: ${filters.type}`);
params.set("categoryType_eq", filters.type);
}
if (filters.status !== "all") {
params.set("status", filters.status);
}
// Only filter by active products if explicitly requested
if (!filters.showInactive) {
params.set("activeProductCount_gt", "0");
}
console.log("Filters:", filters);
console.log("Query params:", params.toString());
return params;
}, [sortColumn, sortDirection, filters]);
@@ -397,21 +408,34 @@ export function Categories() {
} = useQuery<CategoryResponse, Error>({
queryKey: ["categories-all", queryParams.toString()],
queryFn: async () => {
const response = await fetch(
`${config.apiUrl}/categories-aggregate?${queryParams.toString()}`,
{
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
}
);
const url = `${config.apiUrl}/categories-aggregate?${queryParams.toString()}`;
console.log("Fetching categories from URL:", url);
const response = await fetch(url, {
credentials: "include",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
"Cache-Control": "no-cache",
},
});
if (!response.ok) {
throw new Error(`Network response was not ok (${response.status})`);
}
return response.json();
const data = await response.json();
console.log(`Received ${data.categories.length} categories from API`);
if (filters.type !== "all") {
// Check if any categories match the filter
const matchingCategories = data.categories.filter(
(cat: CategoryMetric) => cat.category_type.toString() === filters.type
);
console.log(`Filter type=${filters.type}: ${matchingCategories.length} matching categories found`);
}
return data;
},
staleTime: 0,
});
@@ -447,7 +471,9 @@ export function Categories() {
);
if (!response.ok)
throw new Error("Failed to fetch category filter options");
return response.json();
const data = await response.json();
console.log("Filter options:", data);
return data;
},
});
@@ -459,6 +485,7 @@ export function Categories() {
// Build the hierarchical tree structure
const hierarchicalCategories = useMemo(() => {
console.log(`Building hierarchical structure from ${categories.length} categories`);
if (!categories || categories.length === 0) return [];
// DIRECT CALCULATION: Create a map to directly calculate accurate totals
@@ -479,6 +506,11 @@ export function Categories() {
}
});
// For filtered results, we need a different approach to handle missing parents
// If we've filtered by type, we might have categories without their parents in the results
const isFiltered = filters.type !== 'all' || filters.search !== '';
console.log(`isFiltered: ${isFiltered}, type: ${filters.type}, search: "${filters.search}"`);
// Build sets of all descendants for each category
const allDescendantsMap = new Map<string | number, Set<string | number>>();
@@ -544,6 +576,60 @@ export function Categories() {
categoryMap.set(cat.category_id, { ...cat, children: [] });
});
// When filtering by type, we need to change our approach - all categories should
// be treated as root level categories since we explicitly want to see them
if (isFiltered) {
console.log(`Using flat structure for filtered results (${categories.length} items)`);
// For filtered results, just show a flat list
const filteredCategories = categories.map(cat => {
const processedCat = categoryMap.get(cat.category_id);
if (!processedCat) return null;
// Give these categories a direct parent-child relationship based on parent_id
// if the parent also exists in the filtered results
if (cat.parent_id && categoryMap.has(cat.parent_id)) {
const parent = categoryMap.get(cat.parent_id);
if (parent) {
parent.children.push(processedCat);
}
}
return processedCat;
}).filter(Boolean) as CategoryWithChildren[];
// Only return top-level categories after creating parent-child relationships
const rootFilteredCategories = filteredCategories.filter(cat =>
!cat.parent_id || !categoryMap.has(cat.parent_id)
);
console.log(`Returning ${rootFilteredCategories.length} root filtered categories with type = ${filters.type}`);
// Apply hierarchy levels
const computeHierarchyAndLevels = (
categories: CategoryWithChildren[],
level = 0
) => {
return categories.map((cat, index, arr) => {
// Set hierarchy level
cat.hierarchyLevel = level;
cat.isLast = index === arr.length - 1;
cat.isExpanded = expandedCategories.has(cat.category_id);
// Process children to set their hierarchy levels
const children =
cat.children.length > 0
? computeHierarchyAndLevels(cat.children, level + 1)
: [];
// Aggregated stats already set above
return cat;
});
};
return computeHierarchyAndLevels(rootFilteredCategories);
}
// Regular hierarchical structure for unfiltered results
// Then organize into a hierarchical structure
const rootCategories: CategoryWithChildren[] = [];
@@ -618,9 +704,18 @@ export function Categories() {
// Apply hierarchy levels and use our pre-calculated totals
const result = computeHierarchyAndLevels(rootCategories);
console.log(`Returning ${result.length} hierarchical categories`);
return result;
}, [categories, expandedCategories]);
}, [categories, expandedCategories, filters.type, filters.search]);
// Check if there are no categories to display and explain why
useEffect(() => {
if (hierarchicalCategories.length === 0 && categories.length > 0) {
console.log("Warning: No hierarchical categories to display even though API returned", categories.length, "categories");
console.log("Filter settings:", filters);
}
}, [hierarchicalCategories, categories, filters]);
// Recursive function to render category rows with streamlined stat display
const renderCategoryRow = (
@@ -882,45 +977,51 @@ export function Categories() {
// where it has access to all required variables
const renderGroupedCategories = () => {
if (isLoadingAll) {
return Array.from({ length: 5 }).map((_, i) => (
return Array.from({ length: 10 }).map((_, i) => (
<TableRow key={`skel-${i}`} className="h-16">
<TableCell>
<Skeleton className="h-5 w-40" />
<TableCell className="w-[25%]">
<div className="flex items-center">
<span className="inline-block h-6 w-6 mr-1"></span>
<Skeleton className="h-5 w-40" />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20" />
<TableCell className="w-[95px]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20" />
<TableCell className="w-[15%]">
<Skeleton className="h-5 w-full" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-5 w-16 ml-auto" />
<TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full ml-auto" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-5 w-16 ml-auto" />
<TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full ml-auto" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-5 w-20 ml-auto" />
<TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full ml-auto" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-5 w-20 ml-auto" />
<TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full ml-auto" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-5 w-20 ml-auto" />
<TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full ml-auto" />
</TableCell>
<TableCell className="text-right">
<Skeleton className="h-5 w-16 ml-auto" />
<TableCell className="text-right w-[8%]">
<Skeleton className="h-5 w-full 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 className="text-right w-[6%]">
<Skeleton className="h-5 w-full ml-auto" />
</TableCell>
</TableRow>
));
}
console.log("Rendering categories:", {
hierarchicalCategories: hierarchicalCategories?.length || 0,
categories: categories?.length || 0,
filters
});
if (!hierarchicalCategories || hierarchicalCategories.length === 0) {
// Check hierarchicalCategories directly
return (
@@ -929,18 +1030,28 @@ export function Categories() {
colSpan={11}
className="h-16 text-center py-8 text-muted-foreground"
>
{filters.search || filters.type !== "all" || !filters.showInactive
? "No categories found matching your criteria. Try adjusting filters."
: "No categories available."}
{categories && categories.length > 0 ? (
<>
<p>We found {categories.length} matching categories but encountered an issue displaying them.</p>
<p className="mt-2">Try adjusting your filter criteria or refreshing the page.</p>
</>
) : (
filters.search || filters.type !== "all" || !filters.showInactive
? "No categories found matching your criteria. Try adjusting filters."
: "No categories available."
)}
</TableCell>
</TableRow>
);
}
// Directly render the hierarchical tree roots
return hierarchicalCategories
const rows = hierarchicalCategories
.map((category) => renderCategoryRow(category))
.flat();
console.log(`Rendering ${rows.length} total rows`);
return rows;
};
// --- Event Handlers ---
@@ -948,8 +1059,8 @@ export function Categories() {
const handleSort = useCallback(
(column: CategorySortableColumns) => {
setSortDirection((prev) => {
if (sortColumn !== column) return "asc";
return prev === "asc" ? "desc" : "asc";
if (sortColumn !== column) return "desc";
return prev === "asc" ? "asc" : "desc";
});
setSortColumn(column);
@@ -961,7 +1072,13 @@ export function Categories() {
const handleFilterChange = useCallback(
(filterName: keyof CategoryFilters, value: string | boolean) => {
console.log(`Filter change: ${filterName} = ${value} (${typeof value})`);
setFilters((prev) => ({ ...prev, [filterName]: value }));
// Debug the type filter when changed
if (filterName === 'type') {
console.log(`Type filter changed to: ${value}`);
}
},
[]
);
@@ -973,6 +1090,14 @@ export function Categories() {
}
}, [listError]);
// Log when filter options are received
useEffect(() => {
if (filterOptions) {
console.log("Filter options loaded:", filterOptions);
console.log("Available types:", filterOptions.types);
}
}, [filterOptions]);
// --- Rendering ---
return (
@@ -1135,7 +1260,7 @@ export function Categories() {
<TableRow>
<TableHead
onClick={() => handleSort("categoryName")}
className="cursor-pointer w-[25%]"
className="h-16 cursor-pointer w-[25%]"
>
Name
<SortIndicator active={sortColumn === "categoryName"} />
@@ -1176,32 +1301,32 @@ export function Categories() {
<SortIndicator active={sortColumn === "currentStockCost"} />
</TableHead>
<TableHead
onClick={() => handleSort("revenue_30d")}
onClick={() => handleSort("revenue30d")}
className="cursor-pointer text-right w-[8%]"
>
Revenue (30d)
<SortIndicator active={sortColumn === "revenue_30d"} />
<SortIndicator active={sortColumn === "revenue30d"} />
</TableHead>
<TableHead
onClick={() => handleSort("profit_30d")}
onClick={() => handleSort("profit30d")}
className="cursor-pointer text-right w-[8%]"
>
Profit (30d)
<SortIndicator active={sortColumn === "profit_30d"} />
<SortIndicator active={sortColumn === "profit30d"} />
</TableHead>
<TableHead
onClick={() => handleSort("avg_margin_30d")}
onClick={() => handleSort("avgMargin30d")}
className="cursor-pointer text-right w-[8%]"
>
Margin (30d)
<SortIndicator active={sortColumn === "avg_margin_30d"} />
<SortIndicator active={sortColumn === "avgMargin30d"} />
</TableHead>
<TableHead
onClick={() => handleSort("stock_turn_30d")}
onClick={() => handleSort("stockTurn30d")}
className="cursor-pointer text-right w-[6%]"
>
Stock Turn (30d)
<SortIndicator active={sortColumn === "stock_turn_30d"} />
<SortIndicator active={sortColumn === "stockTurn30d"} />
</TableHead>
</TableRow>
</TableHeader>
@@ -1212,4 +1337,4 @@ export function Categories() {
);
}
export default Categories;
export default Categories;

View File

@@ -55,17 +55,17 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'dateCreated', label: 'Created', group: 'Basic Info' },
// Current Status
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'currentPrice', label: 'Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentRegularPrice', label: 'Regular Price', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentCostPrice', label: 'Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentLandingCostPrice', label: 'Landing Cost', group: 'Pricing', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStock', label: 'Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'currentStockCost', label: 'Stock Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStockRetail', label: 'Stock Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'currentStockGross', label: 'Stock Gross', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'onOrderQty', label: 'On Order', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'onOrderCost', label: 'On Order Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'onOrderRetail', label: 'On Order Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'earliestExpectedDate', label: 'Expected Date', group: 'Stock' },
// Dates
@@ -73,41 +73,45 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'dateLastReceived', label: 'Last Received', group: 'Dates' },
{ key: 'dateFirstSold', label: 'First Sold', group: 'Dates' },
{ key: 'dateLastSold', label: 'Last Sold', group: 'Dates' },
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v?.toString() ?? '-' },
{ key: 'ageDays', label: 'Age (Days)', group: 'Dates', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
// Product Status
{ key: 'status', label: 'Status', group: 'Status' },
// Rolling Metrics
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sales7d', label: 'Sales (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'revenue7d', label: 'Revenue (7d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'sales14d', label: 'Sales (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'revenue14d', label: 'Revenue (14d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'sales30d', label: 'Sales (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'revenue30d', label: 'Revenue (30d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'cogs30d', label: 'COGS (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'profit30d', label: 'Profit (30d)', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'sales365d', label: 'Sales (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'revenue365d', label: 'Revenue (365d)', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
// KPIs
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'margin30d', label: 'Margin %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'markup30d', label: 'Markup %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'gmroi30d', label: 'GMROI', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'stockturn30d', label: 'Stock Turn', group: 'Financial', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'sellThrough30d', label: 'Sell Through %', group: 'Financial', format: (v) => v === 0 ? '0%' : v ? `${v.toFixed(1)}%` : '-' },
{ key: 'avgLeadTimeDays', label: 'Avg Lead Time', group: 'Lead Time', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
// Replenishment
{ key: 'abcClass', label: 'ABC Class', group: 'Stock' },
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'salesVelocityDaily', label: 'Daily Velocity', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'stockCoverInDays', label: 'Stock Cover (Days)', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'sellsOutInDays', label: 'Sells Out In (Days)', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(1) : '-' },
{ key: 'overstockedUnits', label: 'Overstock Qty', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'overstockedCost', label: 'Overstock Cost', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'overstockedRetail', label: 'Overstock Retail', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toFixed(2) : '-' },
{ key: 'isOldStock', label: 'Old Stock', group: 'Stock' },
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'yesterdaySales', label: 'Yesterday Sales', group: 'Sales', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
// Config & Replenishment columns
{ key: 'configSafetyStock', label: 'Safety Stock', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
{ key: 'replenishmentUnits', label: 'Replenish Units', group: 'Stock', format: (v) => v === 0 ? '0' : v ? v.toString() : '-' },
];
// Define default columns for each view
@@ -202,6 +206,10 @@ export function Products() {
const [filters, setFilters] = useState<Record<string, ActiveFilterValue>>({});
const [sortColumn, setSortColumn] = useState<ProductMetricColumnKey>('title');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
// Track last sort direction for each column
const [columnSortDirections, setColumnSortDirections] = useState<Record<string, 'asc' | 'desc'>>({
'title': 'asc' // Initialize with default sort column and direction
});
const [currentPage, setCurrentPage] = useState(1);
const [activeView, setActiveView] = useState(searchParams.get('view') || "all");
const [pageSize] = useState(50);
@@ -283,9 +291,19 @@ export function Products() {
Object.entries(filters).forEach(([key, value]) => {
if (typeof value === 'object' && 'operator' in value) {
transformedFilters[key] = value.value;
transformedFilters[`${key}_operator`] = value.operator;
// Convert the operator format to match what the backend expects
// Backend expects keys like "sales30d_gt" instead of separate operator parameters
const operatorSuffix = value.operator === '=' ? 'eq' :
value.operator === '>' ? 'gt' :
value.operator === '>=' ? 'gte' :
value.operator === '<' ? 'lt' :
value.operator === '<=' ? 'lte' :
value.operator === 'between' ? 'between' : 'eq';
// Create a key with the correct suffix format: key_operator
transformedFilters[`${key}_${operatorSuffix}`] = value.value;
} else {
// Simple values are passed as-is
transformedFilters[key] = value;
}
});
@@ -301,9 +319,10 @@ export function Products() {
params.append('limit', pageSize.toString());
if (sortColumn) {
// Convert camelCase to snake_case for the API
const snakeCaseSort = sortColumn.replace(/([A-Z])/g, '_$1').toLowerCase();
params.append('sort', snakeCaseSort);
// Don't convert camelCase to snake_case - use the column name directly
// as defined in the backend's COLUMN_MAP
console.log(`Sorting: ${sortColumn} (${sortDirection})`);
params.append('sort', sortColumn);
params.append('order', sortDirection);
}
@@ -315,21 +334,22 @@ export function Products() {
const transformedFilters = transformFilters(filters);
Object.entries(transformedFilters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
// Convert camelCase to snake_case for the API
const snakeCaseKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
// Don't convert camelCase to snake_case - use the filter name directly
if (Array.isArray(value)) {
params.append(snakeCaseKey, JSON.stringify(value));
params.append(key, JSON.stringify(value));
} else {
params.append(snakeCaseKey, value.toString());
params.append(key, value.toString());
}
}
});
if (!showNonReplenishable) {
params.append('show_non_replenishable', 'false');
params.append('showNonReplenishable', 'false');
}
// Log the final query parameters for debugging
console.log('API Query:', params.toString());
const response = await fetch(`/api/metrics?${params.toString()}`);
if (!response.ok) throw new Error('Failed to fetch products');
@@ -350,8 +370,8 @@ export function Products() {
// 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)) &&
// Convert numeric strings to actual numbers, but handle empty strings properly
if (typeof value === 'string' && value !== '' && !isNaN(Number(value)) &&
!key.toLowerCase().includes('date') && key !== 'sku' && key !== 'title' &&
key !== 'brand' && key !== 'vendor') {
transformed[camelKey] = Number(value);
@@ -434,13 +454,45 @@ export function Products() {
}
}, [currentPage, data?.pagination.pages]);
// Handle sort column change
// Handle sort column change with improved column-specific direction memory
const handleSort = (column: ProductMetricColumnKey) => {
setSortDirection(prev => {
if (sortColumn !== column) return 'asc';
return prev === 'asc' ? 'desc' : 'asc';
});
let nextDirection: 'asc' | 'desc';
if (sortColumn === column) {
// If clicking the same column, toggle direction
nextDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
// If clicking a different column:
// 1. If this column has been sorted before, use the stored direction
// 2. Otherwise use a sensible default (asc for text, desc for numeric columns)
const prevDirection = columnSortDirections[column];
if (prevDirection) {
// Use the stored direction
nextDirection = prevDirection;
} else {
// Determine sensible default based on column type
const columnDef = AVAILABLE_COLUMNS.find(c => c.key === column);
const isNumeric = columnDef?.group === 'Sales' ||
columnDef?.group === 'Financial' ||
columnDef?.group === 'Stock' ||
['currentPrice', 'currentRegularPrice', 'currentCostPrice', 'currentStock'].includes(column);
// Start with descending for numeric columns (to see highest values first)
// Start with ascending for text columns (alphabetical order)
nextDirection = isNumeric ? 'desc' : 'asc';
}
}
// Update the current sort state
setSortDirection(nextDirection);
setSortColumn(column);
// Remember this column's sort direction for next time
setColumnSortDirections(prev => ({
...prev,
[column]: nextDirection
}));
};
// Handle filter changes
@@ -626,13 +678,11 @@ export function Products() {
) : (
<div className="space-y-4">
<ProductTable
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;
}) || []}
products={data?.products?.map((product: ProductMetric) => ({
...product,
// No need to calculate status anymore since it comes from the backend
status: product.status || 'Healthy' // Fallback only if status is null
})) || []}
onSort={handleSort}
sortColumn={sortColumn}
sortDirection={sortDirection}