Move product status calculation to database, fix up products table, more categories tweaks
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user