Add new filter options and metrics to product filters and pages; enhance SQL schema for financial calculations

This commit is contained in:
2025-03-27 16:27:13 -04:00
parent 8b8845b423
commit 957c7b5eb1
17 changed files with 2216 additions and 482 deletions

View File

@@ -51,7 +51,9 @@ const FILTER_OPTIONS: FilterOption[] = [
// Basic Info Group
{ id: "search", label: "Search", type: "text", group: "Basic Info" },
{ id: "sku", label: "SKU", type: "text", group: "Basic Info" },
{ id: "barcode", label: "UPC/Barcode", type: "text", group: "Basic Info" },
{ id: "vendor", label: "Vendor", type: "select", group: "Basic Info" },
{ id: "vendor_reference", label: "Supplier #", type: "text", group: "Basic Info" },
{ id: "brand", label: "Brand", type: "select", group: "Basic Info" },
{ id: "category", label: "Category", type: "select", group: "Basic Info" },
@@ -84,6 +86,27 @@ const FILTER_OPTIONS: FilterOption[] = [
group: "Inventory",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "weeksOfStock",
label: "Weeks of Stock",
type: "number",
group: "Inventory",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "reorderPoint",
label: "Reorder Point",
type: "number",
group: "Inventory",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "safetyStock",
label: "Safety Stock",
type: "number",
group: "Inventory",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "replenishable",
label: "Replenishable",
@@ -94,6 +117,17 @@ const FILTER_OPTIONS: FilterOption[] = [
],
group: "Inventory",
},
{
id: "abcClass",
label: "ABC Class",
type: "select",
options: [
{ label: "A", value: "A" },
{ label: "B", value: "B" },
{ label: "C", value: "C" },
],
group: "Inventory",
},
// Pricing Group
{
@@ -140,6 +174,32 @@ const FILTER_OPTIONS: FilterOption[] = [
group: "Sales Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "avgQuantityPerOrder",
label: "Avg Qty/Order",
type: "number",
group: "Sales Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "numberOfOrders",
label: "Order Count",
type: "number",
group: "Sales Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "firstSaleDate",
label: "First Sale Date",
type: "text",
group: "Sales Metrics",
},
{
id: "lastSaleDate",
label: "Last Sale Date",
type: "text",
group: "Sales Metrics",
},
// Financial Metrics Group
{
@@ -156,6 +216,34 @@ const FILTER_OPTIONS: FilterOption[] = [
group: "Financial Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "inventoryValue",
label: "Inventory Value",
type: "number",
group: "Financial Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "costOfGoodsSold",
label: "COGS",
type: "number",
group: "Financial Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "grossProfit",
label: "Gross Profit",
type: "number",
group: "Financial Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "turnoverRate",
label: "Turnover Rate",
type: "number",
group: "Financial Metrics",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
// Lead Time & Stock Coverage Group
{
@@ -165,6 +253,20 @@ const FILTER_OPTIONS: FilterOption[] = [
group: "Lead Time & Coverage",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "currentLeadTime",
label: "Current Lead Time",
type: "number",
group: "Lead Time & Coverage",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "targetLeadTime",
label: "Target Lead Time",
type: "number",
group: "Lead Time & Coverage",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
{
id: "leadTimeStatus",
label: "Lead Time Status",
@@ -183,19 +285,26 @@ const FILTER_OPTIONS: FilterOption[] = [
group: "Lead Time & Coverage",
operators: ["=", ">", ">=", "<", "<=", "between"],
},
// Classification Group
{
id: "abcClass",
label: "ABC Class",
type: "select",
options: [
{ label: "A", value: "A" },
{ label: "B", value: "B" },
{ label: "C", value: "C" },
],
group: "Classification",
id: "lastPurchaseDate",
label: "Last Purchase Date",
type: "text",
group: "Lead Time & Coverage",
},
{
id: "firstReceivedDate",
label: "First Received Date",
type: "text",
group: "Lead Time & Coverage",
},
{
id: "lastReceivedDate",
label: "Last Received Date",
type: "text",
group: "Lead Time & Coverage",
},
// Classification Group
{
id: "managingStock",
label: "Managing Stock",

View File

@@ -47,17 +47,16 @@ interface HistoryRecord {
id: number;
start_time: string;
end_time: string | null;
duration_minutes: number;
duration_minutes?: number;
status: "running" | "completed" | "failed" | "cancelled";
error_message: string | null;
additional_info?: Record<string, any>;
}
interface ImportHistoryRecord extends HistoryRecord {
table_name: string;
records_added: number;
records_updated: number;
is_incremental: boolean;
is_incremental?: boolean;
}
interface CalculateHistoryRecord extends HistoryRecord {
@@ -67,6 +66,7 @@ interface CalculateHistoryRecord extends HistoryRecord {
processed_products: number;
processed_orders: number;
processed_purchase_orders: number;
duration_minutes?: number;
}
interface ModuleStatus {
@@ -82,13 +82,14 @@ interface TableStatus {
export function DataManagement() {
const [isUpdating, setIsUpdating] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const [] = useState<ImportProgress | null>(null);
const [eventSource, setEventSource] = useState<EventSource | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [importHistory, setImportHistory] = useState<ImportHistoryRecord[]>([]);
const [calculateHistory, setCalculateHistory] = useState<CalculateHistoryRecord[]>([]);
const [moduleStatus, setModuleStatus] = useState<ModuleStatus[]>([]);
const [tableStatus, setTableStatus] = useState<TableStatus[]>([]);
const [scriptOutput, setScriptOutput] = useState<string[]>([]);
const [eventSource, setEventSource] = useState<EventSource | null>(null);
// Add useRef for scroll handling
const terminalRef = useRef<HTMLDivElement>(null);
@@ -359,11 +360,14 @@ export function DataManagement() {
const fetchHistory = async () => {
try {
setIsLoading(true);
setHasError(false);
const [importRes, calcRes, moduleRes, tableRes] = await Promise.all([
fetch(`${config.apiUrl}/csv/history/import`),
fetch(`${config.apiUrl}/csv/history/calculate`),
fetch(`${config.apiUrl}/csv/status/modules`),
fetch(`${config.apiUrl}/csv/status/tables`),
fetch(`${config.apiUrl}/csv/history/import`, { credentials: 'include' }),
fetch(`${config.apiUrl}/csv/history/calculate`, { credentials: 'include' }),
fetch(`${config.apiUrl}/csv/status/modules`, { credentials: 'include' }),
fetch(`${config.apiUrl}/csv/status/tables`, { credentials: 'include' }),
]);
if (!importRes.ok || !calcRes.ok || !moduleRes.ok || !tableRes.ok) {
@@ -377,18 +381,41 @@ export function DataManagement() {
tableRes.json(),
]);
// Ensure we're setting arrays even if the response is empty or invalid
setImportHistory(Array.isArray(importData) ? importData : []);
setCalculateHistory(Array.isArray(calcData) ? calcData : []);
setModuleStatus(Array.isArray(moduleData) ? moduleData : []);
setTableStatus(Array.isArray(tableData) ? tableData : []);
// Process import history to add duration_minutes if it doesn't exist
const processedImportData = (importData || []).map((record: ImportHistoryRecord) => {
if (!record.duration_minutes && record.start_time && record.end_time) {
const start = new Date(record.start_time).getTime();
const end = new Date(record.end_time).getTime();
record.duration_minutes = (end - start) / (1000 * 60);
}
return record;
});
// Process calculate history to add duration_minutes if it doesn't exist
const processedCalcData = (calcData || []).map((record: CalculateHistoryRecord) => {
if (!record.duration_minutes && record.start_time && record.end_time) {
const start = new Date(record.start_time).getTime();
const end = new Date(record.end_time).getTime();
record.duration_minutes = (end - start) / (1000 * 60);
}
return record;
});
setImportHistory(processedImportData);
setCalculateHistory(processedCalcData);
setModuleStatus(moduleData || []);
setTableStatus(tableData || []);
setHasError(false);
} catch (error) {
console.error("Error fetching history:", error);
// Set empty arrays as fallback
setHasError(true);
toast.error("Failed to load data. Please try again.");
setImportHistory([]);
setCalculateHistory([]);
setModuleStatus([]);
setTableStatus([]);
} finally {
setIsLoading(false);
}
};
@@ -398,6 +425,7 @@ export function DataManagement() {
if (!response.ok) throw new Error('Failed to fetch table status');
const data = await response.json();
setTableStatus(Array.isArray(data) ? data : []);
toast.success("Table status refreshed");
} catch (error) {
toast.error("Failed to refresh table status");
setTableStatus([]);
@@ -441,21 +469,26 @@ export function DataManagement() {
};
const refreshAllData = async () => {
setIsLoading(true);
try {
await Promise.all([
refreshTableStatus(),
refreshModuleStatus(),
refreshImportHistory(),
refreshCalculateHistory()
]);
await fetchHistory();
toast.success("All data refreshed");
} catch (error) {
toast.error("Failed to refresh some data");
toast.error("Failed to refresh data");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
// Fetch data immediately on component mount
fetchHistory();
// Set up periodic refresh every minute
const refreshInterval = setInterval(fetchHistory, 60000);
// Clean up interval on component unmount
return () => clearInterval(refreshInterval);
}, []);
// Add useEffect to handle auto-scrolling
@@ -607,8 +640,13 @@ export function DataManagement() {
size="icon"
onClick={refreshAllData}
className="h-8 w-8"
disabled={isLoading}
>
<RefreshCcw className="h-4 w-4" />
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
</Button>
</div>
@@ -620,7 +658,11 @@ export function DataManagement() {
</CardHeader>
<CardContent>
<div className="">
{tableStatus.length > 0 ? (
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : tableStatus.length > 0 ? (
tableStatus.map((table) => (
<div
key={table.table_name}
@@ -634,12 +676,17 @@ export function DataManagement() {
))
) : (
<div className="text-sm text-muted-foreground py-4 text-center">
No imports have been performed yet.<br/>Run a full update or reset to import data.
{hasError ? (
"Failed to load data. Please try refreshing."
) : (
<>No imports have been performed yet.<br/>Run a full update or reset to import data.</>
)}
</div>
)}
</div>
</CardContent>
</Card>
{/* Module Status */}
<Card>
<CardHeader className="pb-3">
@@ -647,7 +694,11 @@ export function DataManagement() {
</CardHeader>
<CardContent>
<div className="">
{moduleStatus.length > 0 ? (
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
</div>
) : moduleStatus.length > 0 ? (
moduleStatus.map((module) => (
<div
key={module.module_name}
@@ -661,13 +712,18 @@ export function DataManagement() {
))
) : (
<div className="text-sm text-muted-foreground py-4 text-center">
No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.
{hasError ? (
"Failed to load data. Please try refreshing."
) : (
<>No metrics have been calculated yet.<br/>Run a full update or reset to calculate metrics.</>
)}
</div>
)}
</div>
</CardContent>
</Card>
</div>
{/* Recent Import History */}
<Card>
<CardHeader className="pb-3">
@@ -676,7 +732,16 @@ export function DataManagement() {
<CardContent className="px-4 mb-4 max-h-[300px] overflow-y-auto">
<Table>
<TableBody>
{importHistory.length > 0 ? (
{isLoading ? (
<TableRow>
<TableCell className="text-center py-8">
<div className="flex flex-col items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
<span className="text-sm text-muted-foreground">Loading import history...</span>
</div>
</TableCell>
</TableRow>
) : importHistory.length > 0 ? (
importHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
<TableCell className="w-full p-0">
@@ -686,33 +751,41 @@ export function DataManagement() {
className="border-0"
>
<AccordionTrigger className="px-4 py-2">
<div className="flex justify-between items-start w-full pr-4">
<span className="font-medium min-w-[60px]">
#{record.id}
</span>
<span className="text-sm text-gray-600 min-w-[120px]">
{formatDate(record.start_time)}
</span>
<span className="text-sm min-w-[100px]">
{formatDurationWithSeconds(
record.duration_minutes,
record.status === "running",
record.start_time
)}
</span>
<span
className={`min-w-[80px] ${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
<div className="flex justify-between items-center w-full pr-4">
<div className="w-[50px]">
<span className="font-medium">
#{record.id}
</span>
</div>
<div className="w-[170px]">
<span className="text-sm text-gray-600">
{formatDate(record.start_time)}
</span>
</div>
<div className="w-[140px]">
<span className="text-sm">
{formatDurationWithSeconds(
record.duration_minutes || 0,
record.status === "running",
record.start_time
)}
</span>
</div>
<div className="w-[80px]">
<span
className={`${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-2">
@@ -749,7 +822,11 @@ export function DataManagement() {
) : (
<TableRow>
<TableCell className="text-center text-sm text-muted-foreground py-4">
No import history available
{hasError ? (
"Failed to load import history. Please try refreshing."
) : (
"No import history available"
)}
</TableCell>
</TableRow>
)}
@@ -766,7 +843,16 @@ export function DataManagement() {
<CardContent className="px-4 mb-4 max-h-[300px] overflow-y-auto">
<Table>
<TableBody>
{calculateHistory.length > 0 ? (
{isLoading ? (
<TableRow>
<TableCell className="text-center py-8">
<div className="flex flex-col items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-gray-400 mb-2" />
<span className="text-sm text-muted-foreground">Loading calculation history...</span>
</div>
</TableCell>
</TableRow>
) : calculateHistory.length > 0 ? (
calculateHistory.slice(0, 20).map((record) => (
<TableRow key={record.id} className="hover:bg-transparent">
<TableCell className="w-full p-0">
@@ -776,34 +862,41 @@ export function DataManagement() {
className="border-0"
>
<AccordionTrigger className="px-4 py-2">
<div className="flex justify-between items-start w-full pr-4">
<span className="font-medium min-w-[60px]">
#{record.id}
</span>
<span className="text-sm text-gray-600 min-w-[120px]">
{formatDate(record.start_time)}
</span>
<span className="text-sm min-w-[100px]">
{formatDurationWithSeconds(
record.duration_minutes,
record.status === "running",
record.start_time
)}
</span>
<span
className={`min-w-[80px] ${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
<div className="flex justify-between items-center w-full pr-4">
<div className="w-[50px]">
<span className="font-medium">
#{record.id}
</span>
</div>
<div className="w-[170px]">
<span className="text-sm text-gray-600">
{formatDate(record.start_time)}
</span>
</div>
<div className="w-[140px]">
<span className="text-sm">
{formatDurationWithSeconds(
record.duration_minutes || 0,
record.status === "running",
record.start_time
)}
</span>
</div>
<div className="w-[80px]">
<span
className={`${
record.status === "completed"
? "text-green-600"
: record.status === "failed"
? "text-red-600"
: record.status === "cancelled"
? "text-yellow-600"
: "text-blue-600"
}`}
>
{record.status}
</span>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-2">
@@ -817,28 +910,22 @@ export function DataManagement() {
</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Products:
</span>
<span>{record.processed_products}</span>
<span className="text-gray-600">Products:</span>
<span>{record.processed_products} of {record.total_products}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Orders:
</span>
<span>{record.processed_orders}</span>
<span className="text-gray-600">Orders:</span>
<span>{record.processed_orders} of {record.total_orders}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-gray-600">
Processed Purchase Orders:
</span>
<span>{record.processed_purchase_orders}</span>
<span className="text-gray-600">Purchase Orders:</span>
<span>{record.processed_purchase_orders} of {record.total_purchase_orders}</span>
</div>
{record.error_message && (
<div className="text-sm text-red-600 mt-2">
{record.error_message}
</div>
)}
</div>
)}
{record.additional_info &&
formatJsonData(record.additional_info)}
</div>
@@ -851,14 +938,18 @@ export function DataManagement() {
) : (
<TableRow>
<TableCell className="text-center text-sm text-muted-foreground py-4">
No calculation history available
{hasError ? (
"Failed to load calculation history. Please try refreshing."
) : (
"No calculation history available"
)}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</Card>
</div>
</div>
);

View File

@@ -55,10 +55,13 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'stock_quantity', label: 'Shelf Count', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'stock_status', label: 'Stock Status', group: 'Stock' },
{ key: 'days_of_inventory', label: 'Days of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'weeks_of_inventory', label: 'Weeks of Stock', group: 'Stock', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'abc_class', label: 'ABC Class', group: 'Stock' },
{ key: 'replenishable', label: 'Replenishable', group: 'Stock' },
{ key: 'moq', label: 'MOQ', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'reorder_qty', label: 'Reorder Qty', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'reorder_point', label: 'Reorder Point', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'safety_stock', label: 'Safety Stock', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'overstocked_amt', label: 'Overstock Amt', group: 'Stock', format: (v) => v?.toString() ?? '-' },
{ key: 'price', label: 'Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'regular_price', label: 'Default Price', group: 'Pricing', format: (v) => v?.toFixed(2) ?? '-' },
@@ -67,15 +70,22 @@ const AVAILABLE_COLUMNS: ColumnDef[] = [
{ key: 'daily_sales_avg', label: 'Daily Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'weekly_sales_avg', label: 'Weekly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'monthly_sales_avg', label: 'Monthly Sales', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'avg_quantity_per_order', label: 'Avg Qty/Order', group: 'Sales', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'number_of_orders', label: 'Order Count', group: 'Sales', format: (v) => v?.toString() ?? '-' },
{ key: 'first_sale_date', label: 'First Sale', group: 'Sales' },
{ key: 'last_sale_date', label: 'Last Sale', group: 'Sales' },
{ key: 'gmroi', label: 'GMROI', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'turnover_rate', label: 'Turnover Rate', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'avg_margin_percent', label: 'Margin %', group: 'Financial', format: (v) => v ? `${v.toFixed(1)}%` : '-' },
{ key: 'inventory_value', label: 'Inventory Value', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'cost_of_goods_sold', label: 'COGS', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'gross_profit', label: 'Gross Profit', group: 'Financial', format: (v) => v?.toFixed(2) ?? '-' },
{ key: 'current_lead_time', label: 'Current Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'target_lead_time', label: 'Target Lead Time', group: 'Lead Time', format: (v) => v?.toFixed(1) ?? '-' },
{ key: 'lead_time_status', label: 'Lead Time Status', group: 'Lead Time' },
{ key: 'last_purchase_date', label: 'Last Purchase', group: 'Lead Time' },
{ key: 'first_received_date', label: 'First Received', group: 'Lead Time' },
{ key: 'last_received_date', label: 'Last Received', group: 'Lead Time' },
];
// Define default columns for each view
@@ -93,14 +103,17 @@ const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
'daily_sales_avg',
'weekly_sales_avg',
'monthly_sales_avg',
'inventory_value',
],
critical: [
'image',
'title',
'stock_quantity',
'safety_stock',
'daily_sales_avg',
'weekly_sales_avg',
'reorder_qty',
'reorder_point',
'vendor',
'last_purchase_date',
'current_lead_time',
@@ -109,11 +122,13 @@ const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
'image',
'title',
'stock_quantity',
'reorder_point',
'daily_sales_avg',
'weekly_sales_avg',
'reorder_qty',
'vendor',
'last_purchase_date',
'avg_lead_time_days',
],
overstocked: [
'image',
@@ -123,15 +138,19 @@ const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
'weekly_sales_avg',
'overstocked_amt',
'days_of_inventory',
'inventory_value',
'turnover_rate',
],
'at-risk': [
'image',
'title',
'stock_quantity',
'safety_stock',
'daily_sales_avg',
'weekly_sales_avg',
'days_of_inventory',
'last_sale_date',
'current_lead_time',
],
new: [
'image',
@@ -141,6 +160,7 @@ const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
'brand',
'price',
'regular_price',
'first_received_date',
],
healthy: [
'image',
@@ -150,6 +170,8 @@ const VIEW_COLUMNS: Record<string, ColumnKey[]> = {
'weekly_sales_avg',
'monthly_sales_avg',
'days_of_inventory',
'gross_profit',
'gmroi',
],
};