Compare commits
2 Commits
67d57c8872
...
4fdaab9e87
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fdaab9e87 | |||
| 4dcc1f9e90 |
@@ -1,4 +1,4 @@
|
|||||||
1. **Missing Updates for Reorder Point and Safety Stock**
|
1. **Missing Updates for Reorder Point and Safety Stock** [RESOLVED - product-metrics.js]
|
||||||
- **Problem:** In the **product_metrics** table (used by the inventory health view), the fields **reorder_point** and **safety_stock** are never updated in the product metrics calculations. Although a helper function (`calculateReorderQuantities`) exists and computes these values, the update query in the `calculateProductMetrics` function does not assign any values to these columns.
|
- **Problem:** In the **product_metrics** table (used by the inventory health view), the fields **reorder_point** and **safety_stock** are never updated in the product metrics calculations. Although a helper function (`calculateReorderQuantities`) exists and computes these values, the update query in the `calculateProductMetrics` function does not assign any values to these columns.
|
||||||
- **Effect:** The inventory health view relies on these fields (using COALESCE to default them to 0), which means that stock might never be classified as "Reorder" or "Healthy" based on the proper reorder point or safety stock calculations.
|
- **Effect:** The inventory health view relies on these fields (using COALESCE to default them to 0), which means that stock might never be classified as "Reorder" or "Healthy" based on the proper reorder point or safety stock calculations.
|
||||||
- **Example:** Even if a product's base metrics would require a reorder (for example, if its days of inventory are low), the view always shows a value of 0 for reorder_point and safety_stock.
|
- **Example:** Even if a product's base metrics would require a reorder (for example, if its days of inventory are low), the view always shows a value of 0 for reorder_point and safety_stock.
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
- **Example:** An external caller expecting to run `calculateMetrics` would instead receive the `calculateProductMetrics` function.
|
- **Example:** An external caller expecting to run `calculateMetrics` would instead receive the `calculateProductMetrics` function.
|
||||||
- **Fix:** Make sure each script resides in its own module file. Verify that the module boundaries and exports are not accidentally merged or overwritten when deployed.
|
- **Fix:** Make sure each script resides in its own module file. Verify that the module boundaries and exports are not accidentally merged or overwritten when deployed.
|
||||||
|
|
||||||
3. **Potential Formula Issue in EOQ Calculation (Reorder Qty)**
|
3. **Potential Formula Issue in EOQ Calculation (Reorder Qty)** [RESOLVED - product-metrics.js]
|
||||||
- **Problem:** The helper function `calculateReorderQuantities` uses an EOQ formula with a holding cost expressed as a percentage (0.25) rather than a per‐unit cost.
|
- **Problem:** The helper function `calculateReorderQuantities` uses an EOQ formula with a holding cost expressed as a percentage (0.25) rather than a per‐unit cost.
|
||||||
- **Effect:** If the intent was to use the traditional EOQ formula (which expects a holding cost per unit rather than a percentage), this could lead to an incorrect reorder quantity.
|
- **Effect:** If the intent was to use the traditional EOQ formula (which expects a holding cost per unit rather than a percentage), this could lead to an incorrect reorder quantity.
|
||||||
- **Example:** For a given annual demand and fixed order cost, the computed reorder quantity might be higher or lower than expected.
|
- **Example:** For a given annual demand and fixed order cost, the computed reorder quantity might be higher or lower than expected.
|
||||||
|
|||||||
@@ -57,25 +57,36 @@ async function fullReset() {
|
|||||||
message: 'Step 1/3: Resetting database...'
|
message: 'Step 1/3: Resetting database...'
|
||||||
});
|
});
|
||||||
await runScript(path.join(__dirname, 'reset-db.js'));
|
await runScript(path.join(__dirname, 'reset-db.js'));
|
||||||
|
outputProgress({
|
||||||
|
status: 'complete',
|
||||||
|
operation: 'Database reset step complete',
|
||||||
|
message: 'Database reset finished, moving to import...'
|
||||||
|
});
|
||||||
|
|
||||||
// Step 2: Import from Production
|
// Step 2: Import from Production
|
||||||
outputProgress({
|
outputProgress({
|
||||||
operation: 'Database reset complete',
|
operation: 'Starting import',
|
||||||
message: 'Step 2/3: Importing from production...'
|
message: 'Step 2/3: Importing from production...'
|
||||||
});
|
});
|
||||||
await runScript(path.join(__dirname, 'import-from-prod.js'));
|
await runScript(path.join(__dirname, 'import-from-prod.js'));
|
||||||
|
outputProgress({
|
||||||
|
status: 'complete',
|
||||||
|
operation: 'Import step complete',
|
||||||
|
message: 'Import finished, moving to metrics calculation...'
|
||||||
|
});
|
||||||
|
|
||||||
// Step 3: Calculate Metrics
|
// Step 3: Calculate Metrics
|
||||||
outputProgress({
|
outputProgress({
|
||||||
operation: 'Import complete',
|
operation: 'Starting metrics calculation',
|
||||||
message: 'Step 3/3: Calculating metrics...'
|
message: 'Step 3/3: Calculating metrics...'
|
||||||
});
|
});
|
||||||
await runScript(path.join(__dirname, 'calculate-metrics.js'));
|
await runScript(path.join(__dirname, 'calculate-metrics.js'));
|
||||||
|
|
||||||
|
// Final completion message
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
operation: 'Full reset complete',
|
operation: 'Full reset complete',
|
||||||
message: 'Successfully completed database reset, import, and metrics calculation'
|
message: 'Successfully completed all steps: database reset, import, and metrics calculation'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outputProgress({
|
outputProgress({
|
||||||
|
|||||||
@@ -57,18 +57,29 @@ async function fullUpdate() {
|
|||||||
message: 'Step 1/2: Importing from production...'
|
message: 'Step 1/2: Importing from production...'
|
||||||
});
|
});
|
||||||
await runScript(path.join(__dirname, 'import-from-prod.js'));
|
await runScript(path.join(__dirname, 'import-from-prod.js'));
|
||||||
|
outputProgress({
|
||||||
|
status: 'complete',
|
||||||
|
operation: 'Import step complete',
|
||||||
|
message: 'Import finished, moving to metrics calculation...'
|
||||||
|
});
|
||||||
|
|
||||||
// Step 2: Calculate Metrics
|
// Step 2: Calculate Metrics
|
||||||
outputProgress({
|
outputProgress({
|
||||||
operation: 'Import complete',
|
operation: 'Starting metrics calculation',
|
||||||
message: 'Step 2/2: Calculating metrics...'
|
message: 'Step 2/2: Calculating metrics...'
|
||||||
});
|
});
|
||||||
await runScript(path.join(__dirname, 'calculate-metrics.js'));
|
await runScript(path.join(__dirname, 'calculate-metrics.js'));
|
||||||
|
outputProgress({
|
||||||
|
status: 'complete',
|
||||||
|
operation: 'Metrics step complete',
|
||||||
|
message: 'Metrics calculation finished'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Final completion message
|
||||||
outputProgress({
|
outputProgress({
|
||||||
status: 'complete',
|
status: 'complete',
|
||||||
operation: 'Full update complete',
|
operation: 'Full update complete',
|
||||||
message: 'Successfully completed import and metrics calculation'
|
message: 'Successfully completed all steps: import and metrics calculation'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
outputProgress({
|
outputProgress({
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
LEFT JOIN temp_sales_metrics sm ON pm.pid = sm.pid
|
LEFT JOIN temp_sales_metrics sm ON pm.pid = sm.pid
|
||||||
LEFT JOIN temp_purchase_metrics lm ON pm.pid = lm.pid
|
LEFT JOIN temp_purchase_metrics lm ON pm.pid = lm.pid
|
||||||
SET
|
SET
|
||||||
pm.inventory_value = p.stock_quantity * p.cost_price,
|
pm.inventory_value = p.stock_quantity * NULLIF(p.cost_price, 0),
|
||||||
pm.daily_sales_avg = COALESCE(sm.daily_sales_avg, 0),
|
pm.daily_sales_avg = COALESCE(sm.daily_sales_avg, 0),
|
||||||
pm.weekly_sales_avg = COALESCE(sm.weekly_sales_avg, 0),
|
pm.weekly_sales_avg = COALESCE(sm.weekly_sales_avg, 0),
|
||||||
pm.monthly_sales_avg = COALESCE(sm.monthly_sales_avg, 0),
|
pm.monthly_sales_avg = COALESCE(sm.monthly_sales_avg, 0),
|
||||||
@@ -164,12 +164,12 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
pm.avg_lead_time_days = COALESCE(lm.avg_lead_time_days, 30),
|
pm.avg_lead_time_days = COALESCE(lm.avg_lead_time_days, 30),
|
||||||
pm.days_of_inventory = CASE
|
pm.days_of_inventory = CASE
|
||||||
WHEN COALESCE(sm.daily_sales_avg, 0) > 0
|
WHEN COALESCE(sm.daily_sales_avg, 0) > 0
|
||||||
THEN FLOOR(p.stock_quantity / sm.daily_sales_avg)
|
THEN FLOOR(p.stock_quantity / NULLIF(sm.daily_sales_avg, 0))
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END,
|
END,
|
||||||
pm.weeks_of_inventory = CASE
|
pm.weeks_of_inventory = CASE
|
||||||
WHEN COALESCE(sm.weekly_sales_avg, 0) > 0
|
WHEN COALESCE(sm.weekly_sales_avg, 0) > 0
|
||||||
THEN FLOOR(p.stock_quantity / sm.weekly_sales_avg)
|
THEN FLOOR(p.stock_quantity / NULLIF(sm.weekly_sales_avg, 0))
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END,
|
END,
|
||||||
pm.stock_status = CASE
|
pm.stock_status = CASE
|
||||||
@@ -181,10 +181,21 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ? THEN 'Overstocked'
|
WHEN p.stock_quantity / NULLIF(sm.daily_sales_avg, 0) > ? THEN 'Overstocked'
|
||||||
ELSE 'Healthy'
|
ELSE 'Healthy'
|
||||||
END,
|
END,
|
||||||
pm.reorder_qty = CASE
|
pm.safety_stock = CASE
|
||||||
WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN
|
WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN
|
||||||
|
CEIL(sm.daily_sales_avg * SQRT(COALESCE(lm.avg_lead_time_days, 30)) * 1.96)
|
||||||
|
ELSE ?
|
||||||
|
END,
|
||||||
|
pm.reorder_point = CASE
|
||||||
|
WHEN COALESCE(sm.daily_sales_avg, 0) > 0 THEN
|
||||||
|
CEIL(sm.daily_sales_avg * COALESCE(lm.avg_lead_time_days, 30)) +
|
||||||
|
CEIL(sm.daily_sales_avg * SQRT(COALESCE(lm.avg_lead_time_days, 30)) * 1.96)
|
||||||
|
ELSE ?
|
||||||
|
END,
|
||||||
|
pm.reorder_qty = CASE
|
||||||
|
WHEN COALESCE(sm.daily_sales_avg, 0) > 0 AND NULLIF(p.cost_price, 0) IS NOT NULL THEN
|
||||||
GREATEST(
|
GREATEST(
|
||||||
CEIL(sm.daily_sales_avg * COALESCE(lm.avg_lead_time_days, 30) * 1.96),
|
CEIL(SQRT((2 * (sm.daily_sales_avg * 365) * 25) / (NULLIF(p.cost_price, 0) * 0.25))),
|
||||||
?
|
?
|
||||||
)
|
)
|
||||||
ELSE ?
|
ELSE ?
|
||||||
@@ -195,18 +206,22 @@ async function calculateProductMetrics(startTime, totalProducts, processedCount
|
|||||||
ELSE 0
|
ELSE 0
|
||||||
END,
|
END,
|
||||||
pm.last_calculated_at = NOW()
|
pm.last_calculated_at = NOW()
|
||||||
WHERE p.pid IN (?)
|
WHERE p.pid IN (${batch.map(() => '?').join(',')})
|
||||||
`, [
|
`,
|
||||||
|
[
|
||||||
defaultThresholds.low_stock_threshold,
|
defaultThresholds.low_stock_threshold,
|
||||||
defaultThresholds.critical_days,
|
defaultThresholds.critical_days,
|
||||||
defaultThresholds.reorder_days,
|
defaultThresholds.reorder_days,
|
||||||
defaultThresholds.overstock_days,
|
defaultThresholds.overstock_days,
|
||||||
defaultThresholds.low_stock_threshold,
|
defaultThresholds.low_stock_threshold,
|
||||||
defaultThresholds.low_stock_threshold,
|
defaultThresholds.low_stock_threshold,
|
||||||
|
defaultThresholds.low_stock_threshold,
|
||||||
|
defaultThresholds.low_stock_threshold,
|
||||||
defaultThresholds.overstock_days,
|
defaultThresholds.overstock_days,
|
||||||
defaultThresholds.overstock_days,
|
defaultThresholds.overstock_days,
|
||||||
batch.map(row => row.pid)
|
...batch.map(row => row.pid)
|
||||||
]);
|
]
|
||||||
|
);
|
||||||
|
|
||||||
lastPid = batch[batch.length - 1].pid;
|
lastPid = batch[batch.length - 1].pid;
|
||||||
processedCount += batch.length;
|
processedCount += batch.length;
|
||||||
@@ -603,9 +618,9 @@ function calculateReorderQuantities(stock, stock_status, daily_sales_avg, avg_le
|
|||||||
if (daily_sales_avg > 0) {
|
if (daily_sales_avg > 0) {
|
||||||
const annual_demand = daily_sales_avg * 365;
|
const annual_demand = daily_sales_avg * 365;
|
||||||
const order_cost = 25; // Fixed cost per order
|
const order_cost = 25; // Fixed cost per order
|
||||||
const holding_cost_percent = 0.25; // 25% annual holding cost
|
const holding_cost = config.cost_price * 0.25; // 25% of unit cost as annual holding cost
|
||||||
|
|
||||||
reorder_qty = Math.ceil(Math.sqrt((2 * annual_demand * order_cost) / holding_cost_percent));
|
reorder_qty = Math.ceil(Math.sqrt((2 * annual_demand * order_cost) / holding_cost));
|
||||||
} else {
|
} else {
|
||||||
// If no sales data, use a basic calculation
|
// If no sales data, use a basic calculation
|
||||||
reorder_qty = Math.max(safety_stock, config.low_stock_threshold);
|
reorder_qty = Math.max(safety_stock, config.low_stock_threshold);
|
||||||
|
|||||||
@@ -85,8 +85,41 @@ function runScript(scriptPath, type, clients) {
|
|||||||
child.stdout.on('data', (data) => {
|
child.stdout.on('data', (data) => {
|
||||||
const text = data.toString();
|
const text = data.toString();
|
||||||
output += text;
|
output += text;
|
||||||
// Send raw output directly
|
|
||||||
sendProgressToClients(clients, text);
|
// Split by lines to handle multiple JSON outputs
|
||||||
|
const lines = text.split('\n');
|
||||||
|
lines.filter(line => line.trim()).forEach(line => {
|
||||||
|
try {
|
||||||
|
// Try to parse as JSON but don't let it affect the display
|
||||||
|
const jsonData = JSON.parse(line);
|
||||||
|
// Only end the process if we get a final status
|
||||||
|
if (jsonData.status === 'complete' || jsonData.status === 'error' || jsonData.status === 'cancelled') {
|
||||||
|
if (jsonData.status === 'complete' && !jsonData.operation?.includes('complete')) {
|
||||||
|
// Don't close for intermediate completion messages
|
||||||
|
sendProgressToClients(clients, line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Close only on final completion/error/cancellation
|
||||||
|
switch (type) {
|
||||||
|
case 'update':
|
||||||
|
activeFullUpdate = null;
|
||||||
|
break;
|
||||||
|
case 'reset':
|
||||||
|
activeFullReset = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (jsonData.status === 'error') {
|
||||||
|
reject(new Error(jsonData.error || 'Unknown error'));
|
||||||
|
} else {
|
||||||
|
resolve({ output });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, just display as is
|
||||||
|
}
|
||||||
|
// Always send the raw line
|
||||||
|
sendProgressToClients(clients, line);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
child.stderr.on('data', (data) => {
|
child.stderr.on('data', (data) => {
|
||||||
@@ -110,10 +143,8 @@ function runScript(scriptPath, type, clients) {
|
|||||||
const error = `Script ${scriptPath} exited with code ${code}`;
|
const error = `Script ${scriptPath} exited with code ${code}`;
|
||||||
sendProgressToClients(clients, error);
|
sendProgressToClients(clients, error);
|
||||||
reject(new Error(error));
|
reject(new Error(error));
|
||||||
} else {
|
|
||||||
sendProgressToClients(clients, `${type} completed successfully`);
|
|
||||||
resolve({ output });
|
|
||||||
}
|
}
|
||||||
|
// Don't resolve here - let the completion message from the script trigger the resolve
|
||||||
});
|
});
|
||||||
|
|
||||||
child.on('error', (err) => {
|
child.on('error', (err) => {
|
||||||
|
|||||||
@@ -181,24 +181,29 @@ export function DataManagement() {
|
|||||||
// Try to parse for status updates, but don't affect display
|
// Try to parse for status updates, but don't affect display
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (
|
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
|
||||||
data.status === "complete" ||
|
// Only close and reset state if this is the final completion message
|
||||||
data.status === "error" ||
|
if (data.operation === 'Full update complete' ||
|
||||||
data.status === "cancelled"
|
data.status === 'error' ||
|
||||||
) {
|
data.status === 'cancelled') {
|
||||||
source.close();
|
source.close();
|
||||||
setEventSource(null);
|
setEventSource(null);
|
||||||
setIsUpdating(false);
|
setIsUpdating(false);
|
||||||
|
|
||||||
if (data.status === "complete") {
|
if (data.status === 'complete') {
|
||||||
toast.success("Update completed successfully");
|
toast.success("Update completed successfully");
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
} else if (data.status === "error") {
|
} else if (data.status === 'error') {
|
||||||
toast.error(`Update failed: ${data.error || "Unknown error"}`);
|
toast.error(`Update failed: ${data.error || 'Unknown error'}`);
|
||||||
} else {
|
} else {
|
||||||
toast.warning("Update cancelled");
|
toast.warning("Update cancelled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// For intermediate completions, just show a toast
|
||||||
|
else if (data.status === 'complete') {
|
||||||
|
toast.success(data.message || "Step completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Not JSON, just display as is
|
// Not JSON, just display as is
|
||||||
}
|
}
|
||||||
@@ -246,24 +251,29 @@ export function DataManagement() {
|
|||||||
// Try to parse for status updates, but don't affect display
|
// Try to parse for status updates, but don't affect display
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (
|
if (data.status === 'complete' || data.status === 'error' || data.status === 'cancelled') {
|
||||||
data.status === "complete" ||
|
// Only close and reset state if this is the final completion message
|
||||||
data.status === "error" ||
|
if (data.operation === 'Full reset complete' ||
|
||||||
data.status === "cancelled"
|
data.status === 'error' ||
|
||||||
) {
|
data.status === 'cancelled') {
|
||||||
source.close();
|
source.close();
|
||||||
setEventSource(null);
|
setEventSource(null);
|
||||||
setIsResetting(false);
|
setIsResetting(false);
|
||||||
|
|
||||||
if (data.status === "complete") {
|
if (data.status === 'complete') {
|
||||||
toast.success("Reset completed successfully");
|
toast.success("Reset completed successfully");
|
||||||
fetchHistory();
|
fetchHistory();
|
||||||
} else if (data.status === "error") {
|
} else if (data.status === 'error') {
|
||||||
toast.error(`Reset failed: ${data.error || "Unknown error"}`);
|
toast.error(`Reset failed: ${data.error || 'Unknown error'}`);
|
||||||
} else {
|
} else {
|
||||||
toast.warning("Reset cancelled");
|
toast.warning("Reset cancelled");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// For intermediate completions, just show a toast
|
||||||
|
else if (data.status === 'complete') {
|
||||||
|
toast.success(data.message || "Step completed");
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Not JSON, just display as is
|
// Not JSON, just display as is
|
||||||
}
|
}
|
||||||
@@ -511,7 +521,8 @@ export function DataManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="">
|
<div className="">
|
||||||
{tableStatus.map((table) => (
|
{tableStatus.length > 0 ? (
|
||||||
|
tableStatus.map((table) => (
|
||||||
<div
|
<div
|
||||||
key={table.table_name}
|
key={table.table_name}
|
||||||
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
||||||
@@ -521,7 +532,12 @@ export function DataManagement() {
|
|||||||
{formatStatusTime(table.last_sync_timestamp)}
|
{formatStatusTime(table.last_sync_timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -532,7 +548,8 @@ export function DataManagement() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="">
|
<div className="">
|
||||||
{moduleStatus.map((module) => (
|
{moduleStatus.length > 0 ? (
|
||||||
|
moduleStatus.map((module) => (
|
||||||
<div
|
<div
|
||||||
key={module.module_name}
|
key={module.module_name}
|
||||||
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
className="flex justify-between text-sm items-center py-2 border-b last:border-0"
|
||||||
@@ -542,7 +559,12 @@ export function DataManagement() {
|
|||||||
{formatStatusTime(module.last_calculation_timestamp)}
|
{formatStatusTime(module.last_calculation_timestamp)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))
|
||||||
|
) : (
|
||||||
|
<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.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user