Integrate config tables into existing scripts, add new config tables and settings pages

This commit is contained in:
2025-01-12 15:40:27 -05:00
parent b815062525
commit 8172323954
13 changed files with 1671 additions and 126 deletions

View File

@@ -8,6 +8,8 @@ CREATE TABLE IF NOT EXISTS stock_thresholds (
critical_days INT NOT NULL DEFAULT 7,
reorder_days INT NOT NULL DEFAULT 14,
overstock_days INT NOT NULL DEFAULT 90,
low_stock_threshold INT NOT NULL DEFAULT 5,
min_reorder_quantity INT NOT NULL DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
@@ -15,7 +17,75 @@ CREATE TABLE IF NOT EXISTS stock_thresholds (
UNIQUE KEY unique_category_vendor (category_id, vendor)
);
-- Insert default thresholds with ID=1 if not exists
-- Lead time threshold configurations
CREATE TABLE IF NOT EXISTS lead_time_thresholds (
id INT NOT NULL,
category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors
target_days INT NOT NULL DEFAULT 14,
warning_days INT NOT NULL DEFAULT 21,
critical_days INT NOT NULL DEFAULT 30,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor)
);
-- Sales velocity window configurations
CREATE TABLE IF NOT EXISTS sales_velocity_config (
id INT NOT NULL,
category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors
daily_window_days INT NOT NULL DEFAULT 30,
weekly_window_days INT NOT NULL DEFAULT 7,
monthly_window_days INT NOT NULL DEFAULT 90,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor)
);
-- ABC Classification configurations
CREATE TABLE IF NOT EXISTS abc_classification_config (
id INT NOT NULL PRIMARY KEY,
a_threshold DECIMAL(5,2) NOT NULL DEFAULT 20.0,
b_threshold DECIMAL(5,2) NOT NULL DEFAULT 50.0,
classification_period_days INT NOT NULL DEFAULT 90,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
-- Safety stock configurations
CREATE TABLE IF NOT EXISTS safety_stock_config (
id INT NOT NULL,
category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors
coverage_days INT NOT NULL DEFAULT 14,
service_level DECIMAL(5,2) NOT NULL DEFAULT 95.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor)
);
-- Turnover rate configurations
CREATE TABLE IF NOT EXISTS turnover_config (
id INT NOT NULL,
category_id BIGINT, -- NULL means default/global threshold
vendor VARCHAR(100), -- NULL means applies to all vendors
calculation_period_days INT NOT NULL DEFAULT 30,
target_rate DECIMAL(10,2) NOT NULL DEFAULT 1.0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (category_id) REFERENCES categories(id) ON DELETE CASCADE,
UNIQUE KEY unique_category_vendor (category_id, vendor)
);
-- Insert default global thresholds if not exists
INSERT INTO stock_thresholds (id, category_id, vendor, critical_days, reorder_days, overstock_days)
VALUES (1, NULL, NULL, 7, 14, 90)
ON DUPLICATE KEY UPDATE
@@ -23,6 +93,39 @@ ON DUPLICATE KEY UPDATE
reorder_days = VALUES(reorder_days),
overstock_days = VALUES(overstock_days);
INSERT INTO lead_time_thresholds (id, category_id, vendor, target_days, warning_days, critical_days)
VALUES (1, NULL, NULL, 14, 21, 30)
ON DUPLICATE KEY UPDATE
target_days = VALUES(target_days),
warning_days = VALUES(warning_days),
critical_days = VALUES(critical_days);
INSERT INTO sales_velocity_config (id, category_id, vendor, daily_window_days, weekly_window_days, monthly_window_days)
VALUES (1, NULL, NULL, 30, 7, 90)
ON DUPLICATE KEY UPDATE
daily_window_days = VALUES(daily_window_days),
weekly_window_days = VALUES(weekly_window_days),
monthly_window_days = VALUES(monthly_window_days);
INSERT INTO abc_classification_config (id, a_threshold, b_threshold, classification_period_days)
VALUES (1, 20.0, 50.0, 90)
ON DUPLICATE KEY UPDATE
a_threshold = VALUES(a_threshold),
b_threshold = VALUES(b_threshold),
classification_period_days = VALUES(classification_period_days);
INSERT INTO safety_stock_config (id, category_id, vendor, coverage_days, service_level)
VALUES (1, NULL, NULL, 14, 95.0)
ON DUPLICATE KEY UPDATE
coverage_days = VALUES(coverage_days),
service_level = VALUES(service_level);
INSERT INTO turnover_config (id, category_id, vendor, calculation_period_days, target_rate)
VALUES (1, NULL, NULL, 30, 1.0)
ON DUPLICATE KEY UPDATE
calculation_period_days = VALUES(calculation_period_days),
target_rate = VALUES(target_rate);
-- View to show thresholds with category names
CREATE OR REPLACE VIEW stock_thresholds_view AS
SELECT

View File

@@ -1,17 +0,0 @@
-- Indexes for orders table
CREATE INDEX IF NOT EXISTS idx_orders_product_date ON orders(product_id, date);
CREATE INDEX IF NOT EXISTS idx_orders_date ON orders(date);
-- Indexes for purchase_orders table
CREATE INDEX IF NOT EXISTS idx_po_product_date ON purchase_orders(product_id, date);
CREATE INDEX IF NOT EXISTS idx_po_product_status ON purchase_orders(product_id, status);
CREATE INDEX IF NOT EXISTS idx_po_vendor ON purchase_orders(vendor);
-- Indexes for product_metrics table
CREATE INDEX IF NOT EXISTS idx_metrics_revenue ON product_metrics(total_revenue);
-- Indexes for stock_thresholds table
CREATE INDEX IF NOT EXISTS idx_thresholds_category_vendor ON stock_thresholds(category_id, vendor);
-- Indexes for product_categories table
CREATE INDEX IF NOT EXISTS idx_product_categories_both ON product_categories(product_id, category_id);

View File

@@ -155,7 +155,142 @@ async function calculateMetrics() {
const metricsUpdates = [];
for (const product of products) {
try {
// Calculate sales metrics with trends
// Get configuration values for this product
const [configs] = await connection.query(`
WITH product_info AS (
SELECT
p.product_id,
p.vendor,
pc.category_id
FROM products p
LEFT JOIN product_categories pc ON p.product_id = pc.product_id
WHERE p.product_id = ?
),
threshold_options AS (
SELECT
st.*,
CASE
WHEN st.category_id = pi.category_id AND st.vendor = pi.vendor THEN 1 -- Category + vendor match
WHEN st.category_id = pi.category_id AND st.vendor IS NULL THEN 2 -- Category match
WHEN st.category_id IS NULL AND st.vendor = pi.vendor THEN 3 -- Vendor match
WHEN st.category_id IS NULL AND st.vendor IS NULL THEN 4 -- Default
ELSE 5
END as priority
FROM product_info pi
CROSS JOIN stock_thresholds st
WHERE (st.category_id = pi.category_id OR st.category_id IS NULL)
AND (st.vendor = pi.vendor OR st.vendor IS NULL)
),
velocity_options AS (
SELECT
sv.*,
CASE
WHEN sv.category_id = pi.category_id AND sv.vendor = pi.vendor THEN 1
WHEN sv.category_id = pi.category_id AND sv.vendor IS NULL THEN 2
WHEN sv.category_id IS NULL AND sv.vendor = pi.vendor THEN 3
WHEN sv.category_id IS NULL AND sv.vendor IS NULL THEN 4
ELSE 5
END as priority
FROM product_info pi
CROSS JOIN sales_velocity_config sv
WHERE (sv.category_id = pi.category_id OR sv.category_id IS NULL)
AND (sv.vendor = pi.vendor OR sv.vendor IS NULL)
),
safety_options AS (
SELECT
ss.*,
CASE
WHEN ss.category_id = pi.category_id AND ss.vendor = pi.vendor THEN 1
WHEN ss.category_id = pi.category_id AND ss.vendor IS NULL THEN 2
WHEN ss.category_id IS NULL AND ss.vendor = pi.vendor THEN 3
WHEN ss.category_id IS NULL AND ss.vendor IS NULL THEN 4
ELSE 5
END as priority
FROM product_info pi
CROSS JOIN safety_stock_config ss
WHERE (ss.category_id = pi.category_id OR ss.category_id IS NULL)
AND (ss.vendor = pi.vendor OR ss.vendor IS NULL)
)
SELECT
-- Stock thresholds
COALESCE(
(SELECT critical_days
FROM threshold_options
ORDER BY priority LIMIT 1),
7
) as critical_days,
COALESCE(
(SELECT reorder_days
FROM threshold_options
ORDER BY priority LIMIT 1),
14
) as reorder_days,
COALESCE(
(SELECT overstock_days
FROM threshold_options
ORDER BY priority LIMIT 1),
90
) as overstock_days,
COALESCE(
(SELECT low_stock_threshold
FROM threshold_options
ORDER BY priority LIMIT 1),
5
) as low_stock_threshold,
-- Sales velocity windows
COALESCE(
(SELECT daily_window_days
FROM velocity_options
ORDER BY priority LIMIT 1),
30
) as daily_window_days,
COALESCE(
(SELECT weekly_window_days
FROM velocity_options
ORDER BY priority LIMIT 1),
7
) as weekly_window_days,
COALESCE(
(SELECT monthly_window_days
FROM velocity_options
ORDER BY priority LIMIT 1),
90
) as monthly_window_days,
-- Safety stock config
COALESCE(
(SELECT coverage_days
FROM safety_options
ORDER BY priority LIMIT 1),
14
) as safety_stock_days,
COALESCE(
(SELECT service_level
FROM safety_options
ORDER BY priority LIMIT 1),
95.0
) as service_level,
-- ABC Classification
(SELECT a_threshold FROM abc_classification_config WHERE id = 1) as abc_a_threshold,
(SELECT b_threshold FROM abc_classification_config WHERE id = 1) as abc_b_threshold,
(SELECT classification_period_days FROM abc_classification_config WHERE id = 1) as abc_period_days
`, [product.product_id]);
const config = configs[0] || {
critical_days: 7,
reorder_days: 14,
overstock_days: 90,
low_stock_threshold: 5,
daily_window_days: 30,
weekly_window_days: 7,
monthly_window_days: 90,
safety_stock_days: 14,
service_level: 95.0,
abc_a_threshold: 20.0,
abc_b_threshold: 50.0,
abc_period_days: 90
};
// Calculate sales metrics with trends using configured windows
const [salesMetrics] = await connection.query(`
WITH sales_summary AS (
SELECT
@@ -166,9 +301,10 @@ async function calculateMetrics() {
MIN(o.date) as first_sale_date,
COUNT(DISTINCT o.order_number) as number_of_orders,
AVG(o.quantity) as avg_quantity_per_order,
-- Calculate rolling averages
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY) THEN o.quantity ELSE 0 END) as last_30_days_qty,
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN o.quantity ELSE 0 END) as last_7_days_qty
-- Calculate rolling averages using configured windows
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_30_days_qty,
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_7_days_qty,
SUM(CASE WHEN o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY) THEN o.quantity ELSE 0 END) as last_month_qty
FROM orders o
JOIN products p ON o.product_id = p.product_id
WHERE o.canceled = 0 AND o.product_id = ?
@@ -182,11 +318,20 @@ async function calculateMetrics() {
first_sale_date,
number_of_orders,
avg_quantity_per_order,
last_30_days_qty / 30 as rolling_daily_avg,
last_7_days_qty / 7 as rolling_weekly_avg,
last_30_days_qty / ? as rolling_daily_avg,
last_7_days_qty / ? as rolling_weekly_avg,
last_month_qty / ? as rolling_monthly_avg,
total_quantity_sold as total_sales_to_date
FROM sales_summary
`, [product.product_id]).catch(err => {
`, [
config.daily_window_days,
config.weekly_window_days,
config.monthly_window_days,
product.product_id,
config.daily_window_days,
config.weekly_window_days,
config.monthly_window_days
]).catch(err => {
logError(err, `Failed to calculate sales metrics for product ${product.product_id}`);
throw err;
});
@@ -307,9 +452,14 @@ async function calculateMetrics() {
// Calculate stock status using configurable thresholds with proper handling of zero sales
const stock_status = daily_sales_avg === 0 ? 'New' :
stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * threshold.critical_days)) ? 'Critical' :
stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * threshold.reorder_days)) ? 'Reorder' :
stock.stock_quantity > Math.max(1, daily_sales_avg * threshold.overstock_days) ? 'Overstocked' : 'Healthy';
stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * config.critical_days)) ? 'Critical' :
stock.stock_quantity <= Math.max(1, Math.ceil(daily_sales_avg * config.reorder_days)) ? 'Reorder' :
stock.stock_quantity > Math.max(1, daily_sales_avg * config.overstock_days) ? 'Overstocked' : 'Healthy';
// Calculate safety stock using configured values
const safety_stock = daily_sales_avg > 0 ?
Math.max(1, Math.ceil(daily_sales_avg * config.safety_stock_days * (config.service_level / 100))) :
null;
// Add to batch update
metricsUpdates.push([
@@ -323,8 +473,8 @@ async function calculateMetrics() {
metrics.last_sale_date || null,
daily_sales_avg > 0 ? stock.stock_quantity / daily_sales_avg : null,
weekly_sales_avg > 0 ? stock.stock_quantity / weekly_sales_avg : null,
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * threshold.reorder_days)) : null,
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * threshold.critical_days)) : null,
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * config.reorder_days)) : null,
daily_sales_avg > 0 ? Math.max(1, Math.ceil(daily_sales_avg * config.critical_days)) : null,
margin_percent,
metrics.total_revenue || 0,
inventory_value || 0,
@@ -403,21 +553,21 @@ async function calculateMetrics() {
percentage: '100'
});
// Calculate ABC classification
// Calculate ABC classification using configured thresholds
await connection.query(`
WITH revenue_rankings AS (
SELECT
product_id,
total_revenue,
PERCENT_RANK() OVER (ORDER BY COALESCE(total_revenue, 0) DESC) as revenue_rank
PERCENT_RANK() OVER (ORDER BY COALESCE(total_revenue, 0) DESC) * 100 as revenue_percentile
FROM product_metrics
),
classification_update AS (
SELECT
product_id,
CASE
WHEN revenue_rank <= 0.2 THEN 'A'
WHEN revenue_rank <= 0.5 THEN 'B'
WHEN revenue_percentile <= ? THEN 'A'
WHEN revenue_percentile <= ? THEN 'B'
ELSE 'C'
END as abc_class
FROM revenue_rankings
@@ -426,7 +576,7 @@ async function calculateMetrics() {
JOIN classification_update cu ON pm.product_id = cu.product_id
SET pm.abc_class = cu.abc_class,
pm.last_calculated_at = NOW()
`);
`, [config.abc_a_threshold, config.abc_b_threshold]);
// Update progress for time-based aggregates
outputProgress({

View File

@@ -33,6 +33,11 @@ const CORE_TABLES = [
'product_categories'
];
// Config tables that must be created
const CONFIG_TABLES = [
'stock_thresholds'
];
// Split SQL into individual statements
function splitSQLStatements(sql) {
// First, normalize line endings
@@ -361,6 +366,95 @@ async function resetDatabase() {
message: `Successfully created tables: ${CORE_TABLES.join(', ')}`
});
// Read and execute config schema
outputProgress({
operation: 'Running config setup',
message: 'Creating configuration tables...'
});
const configSchemaSQL = fs.readFileSync(
path.join(__dirname, '../db/config-schema.sql'),
'utf8'
);
// Execute config schema statements one at a time
const configStatements = splitSQLStatements(configSchemaSQL);
outputProgress({
operation: 'Config SQL Execution',
message: {
totalStatements: configStatements.length,
statements: configStatements.map((stmt, i) => ({
number: i + 1,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : '')
}))
}
});
for (let i = 0; i < configStatements.length; i++) {
const stmt = configStatements[i];
try {
const [result, fields] = await connection.query(stmt);
// Check for warnings
const [warnings] = await connection.query('SHOW WARNINGS');
if (warnings && warnings.length > 0) {
outputProgress({
status: 'warning',
operation: 'Config SQL Warning',
statement: i + 1,
warnings: warnings
});
}
outputProgress({
operation: 'Config SQL Progress',
message: {
statement: i + 1,
total: configStatements.length,
preview: stmt.substring(0, 100) + (stmt.length > 100 ? '...' : ''),
affectedRows: result.affectedRows
}
});
} catch (sqlError) {
outputProgress({
status: 'error',
operation: 'Config SQL Error',
error: sqlError.message,
sqlState: sqlError.sqlState,
errno: sqlError.errno,
statement: stmt,
statementNumber: i + 1
});
throw sqlError;
}
}
// Verify config tables were created
const [showConfigTables] = await connection.query('SHOW TABLES');
const existingConfigTables = showConfigTables.map(t => Object.values(t)[0]);
outputProgress({
operation: 'Config tables verification',
message: {
found: existingConfigTables,
expected: CONFIG_TABLES
}
});
const missingConfigTables = CONFIG_TABLES.filter(
t => !existingConfigTables.includes(t)
);
if (missingConfigTables.length > 0) {
throw new Error(
`Failed to create config tables: ${missingConfigTables.join(', ')}`
);
}
outputProgress({
operation: 'Config tables created',
message: `Successfully created tables: ${CONFIG_TABLES.join(', ')}`
});
// Read and execute metrics schema (metrics tables)
outputProgress({
operation: 'Running metrics setup',

View File

@@ -24,6 +24,11 @@ const METRICS_TABLES = [
'vendor_metrics'
];
// Config tables that must exist
const CONFIG_TABLES = [
'stock_thresholds'
];
// Core tables that must exist
const REQUIRED_CORE_TABLES = [
'products',
@@ -129,10 +134,23 @@ async function resetMetrics() {
// the metrics tables and indexes in one shot
await connection.query(schemaSQL);
// Read and execute config schema
outputProgress({
status: 'running',
operation: 'Creating configuration tables',
percentage: '60'
});
const configSchemaPath = path.join(__dirname, '../db/config-schema.sql');
const configSchemaSQL = fs.readFileSync(configSchemaPath, 'utf8');
// Run the config schema
await connection.query(configSchemaSQL);
// Verify all tables were actually created using SHOW TABLES
const [verifyTables] = await connection.query('SHOW TABLES');
const tablesAfterCreation = verifyTables.map(t => Object.values(t)[0]);
// First verify metrics tables
outputProgress({
status: 'running',
operation: 'Verifying metrics tables',
@@ -142,13 +160,33 @@ async function resetMetrics() {
}
});
const missingTables = METRICS_TABLES.filter(
const missingMetricsTables = METRICS_TABLES.filter(
t => !tablesAfterCreation.includes(t)
);
if (missingTables.length > 0) {
if (missingMetricsTables.length > 0) {
throw new Error(
`Failed to create tables: ${missingTables.join(', ')}`
`Failed to create metrics tables: ${missingMetricsTables.join(', ')}`
);
}
// Then verify config tables
outputProgress({
status: 'running',
operation: 'Verifying config tables',
message: {
found: tablesAfterCreation,
required: CONFIG_TABLES
}
});
const missingConfigTables = CONFIG_TABLES.filter(
t => !tablesAfterCreation.includes(t)
);
if (missingConfigTables.length > 0) {
throw new Error(
`Failed to create config tables: ${missingConfigTables.join(', ')}`
);
}
@@ -157,7 +195,7 @@ async function resetMetrics() {
outputProgress({
status: 'complete',
operation: 'Metrics tables have been reset',
operation: 'Metrics and config tables have been reset',
percentage: '100'
});

View File

@@ -189,6 +189,21 @@ router.get('/stock', async (req, res) => {
try {
const pool = req.app.locals.pool;
// Get global configuration values
const [configs] = await pool.query(`
SELECT
st.low_stock_threshold,
tc.calculation_period_days as turnover_period
FROM stock_thresholds st
CROSS JOIN turnover_config tc
WHERE st.id = 1 AND tc.id = 1
`);
const config = configs[0] || {
low_stock_threshold: 5,
turnover_period: 30
};
// Get turnover by category
const [turnoverByCategory] = await pool.query(`
SELECT
@@ -200,48 +215,84 @@ router.get('/stock', async (req, res) => {
LEFT JOIN orders o ON p.product_id = o.product_id
JOIN product_categories pc ON p.product_id = pc.product_id
JOIN categories c ON pc.category_id = c.id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY c.name
HAVING turnoverRate > 0
ORDER BY turnoverRate DESC
LIMIT 10
`);
`, [config.turnover_period]);
// Get stock levels over time (last 30 days)
// Get stock levels over time
const [stockLevels] = await pool.query(`
SELECT
DATE_FORMAT(o.date, '%Y-%m-%d') as date,
SUM(CASE WHEN p.stock_quantity > 5 THEN 1 ELSE 0 END) as inStock,
SUM(CASE WHEN p.stock_quantity <= 5 AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
SUM(CASE WHEN p.stock_quantity > ? THEN 1 ELSE 0 END) as inStock,
SUM(CASE WHEN p.stock_quantity <= ? AND p.stock_quantity > 0 THEN 1 ELSE 0 END) as lowStock,
SUM(CASE WHEN p.stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY DATE_FORMAT(o.date, '%Y-%m-%d')
ORDER BY date
`);
`, [
config.low_stock_threshold,
config.low_stock_threshold,
config.turnover_period
]);
// Get critical stock items
const [criticalItems] = await pool.query(`
WITH product_thresholds AS (
SELECT
p.product_id,
COALESCE(
(SELECT reorder_days
FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id
WHERE pc.product_id = p.product_id
AND st.vendor = p.vendor LIMIT 1),
(SELECT reorder_days
FROM stock_thresholds st
JOIN product_categories pc ON st.category_id = pc.category_id
WHERE pc.product_id = p.product_id
AND st.vendor IS NULL LIMIT 1),
(SELECT reorder_days
FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor = p.vendor LIMIT 1),
(SELECT reorder_days
FROM stock_thresholds st
WHERE st.category_id IS NULL
AND st.vendor IS NULL LIMIT 1),
14
) as reorder_days
FROM products p
)
SELECT
p.title as product,
p.SKU as sku,
p.stock_quantity as stockQuantity,
GREATEST(ROUND(AVG(o.quantity) * 7), 5) as reorderPoint,
GREATEST(ROUND(AVG(o.quantity) * pt.reorder_days), ?) as reorderPoint,
ROUND(SUM(o.quantity) / NULLIF(p.stock_quantity, 0), 1) as turnoverRate,
CASE
WHEN p.stock_quantity = 0 THEN 0
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / 30), 0))
ELSE ROUND(p.stock_quantity / NULLIF((SUM(o.quantity) / ?), 0))
END as daysUntilStockout
FROM products p
LEFT JOIN orders o ON p.product_id = o.product_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
JOIN product_thresholds pt ON p.product_id = pt.product_id
WHERE o.date >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
AND p.managing_stock = true
GROUP BY p.product_id
HAVING daysUntilStockout < 30 AND daysUntilStockout >= 0
HAVING daysUntilStockout < ? AND daysUntilStockout >= 0
ORDER BY daysUntilStockout
LIMIT 10
`);
`, [
config.low_stock_threshold,
config.turnover_period,
config.turnover_period,
config.turnover_period
]);
res.json({ turnoverByCategory, stockLevels, criticalItems });
} catch (error) {

View File

@@ -267,12 +267,27 @@ router.get('/trending-products', async (req, res) => {
router.get('/inventory-metrics', async (req, res) => {
const pool = req.app.locals.pool;
try {
// Get global configuration values
const [configs] = await pool.query(`
SELECT
st.low_stock_threshold,
tc.calculation_period_days as turnover_period
FROM stock_thresholds st
CROSS JOIN turnover_config tc
WHERE st.id = 1 AND tc.id = 1
`);
const config = configs[0] || {
low_stock_threshold: 5,
turnover_period: 30
};
// Get stock levels by category
const [stockLevels] = await pool.query(`
SELECT
c.name as category,
SUM(CASE WHEN stock_quantity > 5 THEN 1 ELSE 0 END) as inStock,
SUM(CASE WHEN stock_quantity > 0 AND stock_quantity <= 5 THEN 1 ELSE 0 END) as lowStock,
SUM(CASE WHEN stock_quantity > ? THEN 1 ELSE 0 END) as inStock,
SUM(CASE WHEN stock_quantity > 0 AND stock_quantity <= ? THEN 1 ELSE 0 END) as lowStock,
SUM(CASE WHEN stock_quantity = 0 THEN 1 ELSE 0 END) as outOfStock
FROM products p
JOIN product_categories pc ON p.product_id = pc.product_id
@@ -280,7 +295,7 @@ router.get('/inventory-metrics', async (req, res) => {
WHERE visible = true
GROUP BY c.name
ORDER BY c.name ASC
`);
`, [config.low_stock_threshold, config.low_stock_threshold]);
// Get top vendors with product counts and average stock
const [topVendors] = await pool.query(`
@@ -298,7 +313,6 @@ router.get('/inventory-metrics', async (req, res) => {
`);
// Calculate stock turnover rate by category
// Turnover = Units sold in last 30 days / Average inventory level
const [stockTurnover] = await pool.query(`
WITH CategorySales AS (
SELECT
@@ -309,7 +323,7 @@ router.get('/inventory-metrics', async (req, res) => {
JOIN product_categories pc ON p.product_id = pc.product_id
JOIN categories c ON pc.category_id = c.id
WHERE o.canceled = false
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
AND DATE(o.date) >= DATE_SUB(CURDATE(), INTERVAL ? DAY)
GROUP BY c.name
),
CategoryStock AS (
@@ -331,25 +345,9 @@ router.get('/inventory-metrics', async (req, res) => {
FROM CategorySales cs
JOIN CategoryStock cst ON cs.category = cst.category
ORDER BY rate DESC
`);
`, [config.turnover_period]);
res.json({
stockLevels: stockLevels.map(row => ({
...row,
inStock: parseInt(row.inStock || 0),
lowStock: parseInt(row.lowStock || 0),
outOfStock: parseInt(row.outOfStock || 0)
})),
topVendors: topVendors.map(row => ({
vendor: row.vendor,
productCount: parseInt(row.productCount || 0),
averageStockLevel: parseFloat(row.averageStockLevel || 0)
})),
stockTurnover: stockTurnover.map(row => ({
category: row.category,
rate: parseFloat(row.rate || 0)
}))
});
res.json({ stockLevels, topVendors, stockTurnover });
} catch (error) {
console.error('Error fetching inventory metrics:', error);
res.status(500).json({ error: 'Failed to fetch inventory metrics' });

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import config from '../../config';
interface SalesVelocityConfig {
id: number;
category_id: number | null;
vendor: string | null;
daily_window_days: number;
weekly_window_days: number;
monthly_window_days: number;
}
export function CalculationSettings() {
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
id: 1,
category_id: null,
vendor: null,
daily_window_days: 30,
weekly_window_days: 7,
monthly_window_days: 90
});
const handleUpdateSalesVelocityConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/sales-velocity/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(salesVelocityConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update sales velocity configuration');
}
toast.success('Sales velocity configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
return (
<div className="space-y-4">
{/* Sales Velocity Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Sales Velocity Windows</CardTitle>
<CardDescription>Configure time windows for sales velocity calculations</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="daily-window">Daily Window (days)</Label>
<Input
id="daily-window"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={salesVelocityConfig.daily_window_days}
onChange={(e) => setSalesVelocityConfig(prev => ({
...prev,
daily_window_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="weekly-window">Weekly Window (days)</Label>
<Input
id="weekly-window"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={salesVelocityConfig.weekly_window_days}
onChange={(e) => setSalesVelocityConfig(prev => ({
...prev,
weekly_window_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="monthly-window">Monthly Window (days)</Label>
<Input
id="monthly-window"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={salesVelocityConfig.monthly_window_days}
onChange={(e) => setSalesVelocityConfig(prev => ({
...prev,
monthly_window_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateSalesVelocityConfig}>
Update Sales Velocity Windows
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { toast } from "sonner";
import config from '../../config';
@@ -13,21 +14,107 @@ interface StockThreshold {
critical_days: number;
reorder_days: number;
overstock_days: number;
low_stock_threshold: number;
min_reorder_quantity: number;
category_name?: string;
threshold_scope?: string;
}
interface LeadTimeThreshold {
id: number;
category_id: number | null;
vendor: string | null;
target_days: number;
warning_days: number;
critical_days: number;
}
interface SalesVelocityConfig {
id: number;
category_id: number | null;
vendor: string | null;
daily_window_days: number;
weekly_window_days: number;
monthly_window_days: number;
}
interface ABCClassificationConfig {
id: number;
a_threshold: number;
b_threshold: number;
classification_period_days: number;
}
interface SafetyStockConfig {
id: number;
category_id: number | null;
vendor: string | null;
coverage_days: number;
service_level: number;
}
interface TurnoverConfig {
id: number;
category_id: number | null;
vendor: string | null;
calculation_period_days: number;
target_rate: number;
}
export function Configuration() {
const [globalThresholds, setGlobalThresholds] = useState<StockThreshold>({
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
id: 1,
category_id: null,
vendor: null,
critical_days: 7,
reorder_days: 14,
overstock_days: 90
overstock_days: 90,
low_stock_threshold: 5,
min_reorder_quantity: 1
});
const handleUpdateGlobalThresholds = async () => {
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
id: 1,
category_id: null,
vendor: null,
target_days: 14,
warning_days: 21,
critical_days: 30
});
const [salesVelocityConfig, setSalesVelocityConfig] = useState<SalesVelocityConfig>({
id: 1,
category_id: null,
vendor: null,
daily_window_days: 30,
weekly_window_days: 7,
monthly_window_days: 90
});
const [abcConfig, setAbcConfig] = useState<ABCClassificationConfig>({
id: 1,
a_threshold: 20.0,
b_threshold: 50.0,
classification_period_days: 90
});
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
id: 1,
category_id: null,
vendor: null,
coverage_days: 14,
service_level: 95.0
});
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
id: 1,
category_id: null,
vendor: null,
calculation_period_days: 30,
target_rate: 1.0
});
const handleUpdateStockThresholds = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/stock-thresholds/1`, {
method: 'PUT',
@@ -35,22 +122,139 @@ export function Configuration() {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(globalThresholds)
body: JSON.stringify(stockThresholds)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update global thresholds');
throw new Error(data.error || 'Failed to update stock thresholds');
}
toast.success('Global thresholds updated successfully');
toast.success('Stock thresholds updated successfully');
} catch (error) {
toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateLeadTimeThresholds = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/lead-time-thresholds/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(leadTimeThresholds)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update lead time thresholds');
}
toast.success('Lead time thresholds updated successfully');
} catch (error) {
toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateSalesVelocityConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/sales-velocity/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(salesVelocityConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update sales velocity configuration');
}
toast.success('Sales velocity configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateABCConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/abc-classification/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(abcConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update ABC classification configuration');
}
toast.success('ABC classification configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateSafetyStockConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/safety-stock/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(safetyStockConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update safety stock configuration');
}
toast.success('Safety stock configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateTurnoverConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/turnover/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(turnoverConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update turnover configuration');
}
toast.success('Turnover configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Tabs defaultValue="stock" className="w-full">
<TabsList>
<TabsTrigger value="stock">Stock Management</TabsTrigger>
<TabsTrigger value="performance">Performance Metrics</TabsTrigger>
<TabsTrigger value="calculation">Calculation Settings</TabsTrigger>
</TabsList>
<TabsContent value="stock" className="space-y-4">
{/* Stock Thresholds Card */}
<Card>
<CardHeader>
@@ -59,18 +263,15 @@ export function Configuration() {
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Global Defaults Section */}
<div>
<h3 className="text-sm font-medium mb-2">Global Defaults</h3>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="critical-days">Critical Days</Label>
<Input
id="critical-days"
type="number"
min="1"
value={globalThresholds.critical_days}
onChange={(e) => setGlobalThresholds(prev => ({
value={stockThresholds.critical_days}
onChange={(e) => setStockThresholds(prev => ({
...prev,
critical_days: parseInt(e.target.value) || 1
}))}
@@ -82,8 +283,8 @@ export function Configuration() {
id="reorder-days"
type="number"
min="1"
value={globalThresholds.reorder_days}
onChange={(e) => setGlobalThresholds(prev => ({
value={stockThresholds.reorder_days}
onChange={(e) => setStockThresholds(prev => ({
...prev,
reorder_days: parseInt(e.target.value) || 1
}))}
@@ -95,32 +296,312 @@ export function Configuration() {
id="overstock-days"
type="number"
min="1"
value={globalThresholds.overstock_days}
onChange={(e) => setGlobalThresholds(prev => ({
value={stockThresholds.overstock_days}
onChange={(e) => setStockThresholds(prev => ({
...prev,
overstock_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button
className="mt-2"
onClick={handleUpdateGlobalThresholds}
>
Update Global Defaults
</Button>
</div>
{/* Category/Vendor Specific Section */}
<div>
<h3 className="text-sm font-medium mb-2">Category & Vendor Specific</h3>
<Button variant="outline" className="w-full">Add New Threshold Rule</Button>
<Label htmlFor="low-stock-threshold">Low Stock Threshold</Label>
<Input
id="low-stock-threshold"
type="number"
min="0"
value={stockThresholds.low_stock_threshold}
onChange={(e) => setStockThresholds(prev => ({
...prev,
low_stock_threshold: parseInt(e.target.value) || 0
}))}
/>
</div>
<div>
<Label htmlFor="min-reorder-quantity">Minimum Reorder Quantity</Label>
<Input
id="min-reorder-quantity"
type="number"
min="1"
value={stockThresholds.min_reorder_quantity}
onChange={(e) => setStockThresholds(prev => ({
...prev,
min_reorder_quantity: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateStockThresholds}>
Update Stock Thresholds
</Button>
</div>
</CardContent>
</Card>
{/* Future Config Cards can go here */}
{/* Safety Stock Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Safety Stock</CardTitle>
<CardDescription>Configure safety stock parameters</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="coverage-days">Coverage Days</Label>
<Input
id="coverage-days"
type="number"
min="1"
value={safetyStockConfig.coverage_days}
onChange={(e) => setSafetyStockConfig(prev => ({
...prev,
coverage_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="service-level">Service Level (%)</Label>
<Input
id="service-level"
type="number"
min="0"
max="100"
step="0.1"
value={safetyStockConfig.service_level}
onChange={(e) => setSafetyStockConfig(prev => ({
...prev,
service_level: parseFloat(e.target.value) || 0
}))}
/>
</div>
</div>
<Button onClick={handleUpdateSafetyStockConfig}>
Update Safety Stock Configuration
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="performance" className="space-y-4">
{/* Lead Time Thresholds Card */}
<Card>
<CardHeader>
<CardTitle>Lead Time Thresholds</CardTitle>
<CardDescription>Configure lead time thresholds for vendor performance</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="target-days">Target Days</Label>
<Input
id="target-days"
type="number"
min="1"
value={leadTimeThresholds.target_days}
onChange={(e) => setLeadTimeThresholds(prev => ({
...prev,
target_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="warning-days">Warning Days</Label>
<Input
id="warning-days"
type="number"
min="1"
value={leadTimeThresholds.warning_days}
onChange={(e) => setLeadTimeThresholds(prev => ({
...prev,
warning_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="critical-days-lead">Critical Days</Label>
<Input
id="critical-days-lead"
type="number"
min="1"
value={leadTimeThresholds.critical_days}
onChange={(e) => setLeadTimeThresholds(prev => ({
...prev,
critical_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateLeadTimeThresholds}>
Update Lead Time Thresholds
</Button>
</div>
</CardContent>
</Card>
{/* ABC Classification Card */}
<Card>
<CardHeader>
<CardTitle>ABC Classification</CardTitle>
<CardDescription>Configure ABC classification parameters</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="a-threshold">A Threshold (%)</Label>
<Input
id="a-threshold"
type="number"
min="0"
max="100"
step="0.1"
value={abcConfig.a_threshold}
onChange={(e) => setAbcConfig(prev => ({
...prev,
a_threshold: parseFloat(e.target.value) || 0
}))}
/>
</div>
<div>
<Label htmlFor="b-threshold">B Threshold (%)</Label>
<Input
id="b-threshold"
type="number"
min="0"
max="100"
step="0.1"
value={abcConfig.b_threshold}
onChange={(e) => setAbcConfig(prev => ({
...prev,
b_threshold: parseFloat(e.target.value) || 0
}))}
/>
</div>
<div>
<Label htmlFor="classification-period">Classification Period (days)</Label>
<Input
id="classification-period"
type="number"
min="1"
value={abcConfig.classification_period_days}
onChange={(e) => setAbcConfig(prev => ({
...prev,
classification_period_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateABCConfig}>
Update ABC Classification
</Button>
</div>
</CardContent>
</Card>
{/* Turnover Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Turnover Rate</CardTitle>
<CardDescription>Configure turnover rate calculations</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="calculation-period">Calculation Period (days)</Label>
<Input
id="calculation-period"
type="number"
min="1"
value={turnoverConfig.calculation_period_days}
onChange={(e) => setTurnoverConfig(prev => ({
...prev,
calculation_period_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="target-rate">Target Rate</Label>
<Input
id="target-rate"
type="number"
min="0"
step="0.1"
value={turnoverConfig.target_rate}
onChange={(e) => setTurnoverConfig(prev => ({
...prev,
target_rate: parseFloat(e.target.value) || 0
}))}
/>
</div>
</div>
<Button onClick={handleUpdateTurnoverConfig}>
Update Turnover Configuration
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="calculation" className="space-y-4">
{/* Sales Velocity Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Sales Velocity Windows</CardTitle>
<CardDescription>Configure time windows for sales velocity calculations</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="daily-window">Daily Window (days)</Label>
<Input
id="daily-window"
type="number"
min="1"
value={salesVelocityConfig.daily_window_days}
onChange={(e) => setSalesVelocityConfig(prev => ({
...prev,
daily_window_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="weekly-window">Weekly Window (days)</Label>
<Input
id="weekly-window"
type="number"
min="1"
value={salesVelocityConfig.weekly_window_days}
onChange={(e) => setSalesVelocityConfig(prev => ({
...prev,
weekly_window_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="monthly-window">Monthly Window (days)</Label>
<Input
id="monthly-window"
type="number"
min="1"
value={salesVelocityConfig.monthly_window_days}
onChange={(e) => setSalesVelocityConfig(prev => ({
...prev,
monthly_window_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateSalesVelocityConfig}>
Update Sales Velocity Windows
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
);
}

View File

@@ -2,8 +2,6 @@ import { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
AlertDialog,
AlertDialogAction,
@@ -53,7 +51,7 @@ export function DataManagement() {
const [purchaseOrdersProgress, setPurchaseOrdersProgress] = useState<ImportProgress | null>(null);
const [resetProgress, setResetProgress] = useState<ImportProgress | null>(null);
const [eventSource, setEventSource] = useState<EventSource | null>(null);
const [limits, setLimits] = useState<ImportLimits>({
const [limits] = useState<ImportLimits>({
products: 0,
orders: 0,
purchaseOrders: 0

View File

@@ -0,0 +1,295 @@
import { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import config from '../../config';
interface LeadTimeThreshold {
id: number;
category_id: number | null;
vendor: string | null;
target_days: number;
warning_days: number;
critical_days: number;
}
interface ABCClassificationConfig {
id: number;
a_threshold: number;
b_threshold: number;
classification_period_days: number;
}
interface TurnoverConfig {
id: number;
category_id: number | null;
vendor: string | null;
calculation_period_days: number;
target_rate: number;
}
export function PerformanceMetrics() {
const [leadTimeThresholds, setLeadTimeThresholds] = useState<LeadTimeThreshold>({
id: 1,
category_id: null,
vendor: null,
target_days: 14,
warning_days: 21,
critical_days: 30
});
const [abcConfig, setAbcConfig] = useState<ABCClassificationConfig>({
id: 1,
a_threshold: 20.0,
b_threshold: 50.0,
classification_period_days: 90
});
const [turnoverConfig, setTurnoverConfig] = useState<TurnoverConfig>({
id: 1,
category_id: null,
vendor: null,
calculation_period_days: 30,
target_rate: 1.0
});
const handleUpdateLeadTimeThresholds = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/lead-time-thresholds/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(leadTimeThresholds)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update lead time thresholds');
}
toast.success('Lead time thresholds updated successfully');
} catch (error) {
toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateABCConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/abc-classification/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(abcConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update ABC classification configuration');
}
toast.success('ABC classification configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateTurnoverConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/turnover/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(turnoverConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update turnover configuration');
}
toast.success('Turnover configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
return (
<div className="space-y-4">
{/* Lead Time Thresholds Card */}
<Card>
<CardHeader>
<CardTitle>Lead Time Thresholds</CardTitle>
<CardDescription>Configure lead time thresholds for vendor performance</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="target-days">Target Days</Label>
<Input
id="target-days"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={leadTimeThresholds.target_days}
onChange={(e) => setLeadTimeThresholds(prev => ({
...prev,
target_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="warning-days">Warning Days</Label>
<Input
id="warning-days"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={leadTimeThresholds.warning_days}
onChange={(e) => setLeadTimeThresholds(prev => ({
...prev,
warning_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="critical-days-lead">Critical Days</Label>
<Input
id="critical-days-lead"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={leadTimeThresholds.critical_days}
onChange={(e) => setLeadTimeThresholds(prev => ({
...prev,
critical_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateLeadTimeThresholds}>
Update Lead Time Thresholds
</Button>
</div>
</CardContent>
</Card>
{/* ABC Classification Card */}
<Card>
<CardHeader>
<CardTitle>ABC Classification</CardTitle>
<CardDescription>Configure ABC classification parameters</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-3 gap-4">
<div>
<Label htmlFor="a-threshold">A Threshold (%)</Label>
<Input
id="a-threshold"
type="number"
min="0"
max="100"
step="0.1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={abcConfig.a_threshold}
onChange={(e) => setAbcConfig(prev => ({
...prev,
a_threshold: parseFloat(e.target.value) || 0
}))}
/>
</div>
<div>
<Label htmlFor="b-threshold">B Threshold (%)</Label>
<Input
id="b-threshold"
type="number"
min="0"
max="100"
step="0.1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={abcConfig.b_threshold}
onChange={(e) => setAbcConfig(prev => ({
...prev,
b_threshold: parseFloat(e.target.value) || 0
}))}
/>
</div>
<div>
<Label htmlFor="classification-period">Classification Period (days)</Label>
<Input
id="classification-period"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={abcConfig.classification_period_days}
onChange={(e) => setAbcConfig(prev => ({
...prev,
classification_period_days: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateABCConfig}>
Update ABC Classification
</Button>
</div>
</CardContent>
</Card>
{/* Turnover Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Turnover Rate</CardTitle>
<CardDescription>Configure turnover rate calculations</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="calculation-period">Calculation Period (days)</Label>
<Input
id="calculation-period"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={turnoverConfig.calculation_period_days}
onChange={(e) => setTurnoverConfig(prev => ({
...prev,
calculation_period_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="target-rate">Target Rate</Label>
<Input
id="target-rate"
type="number"
min="0"
step="0.1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={turnoverConfig.target_rate}
onChange={(e) => setTurnoverConfig(prev => ({
...prev,
target_rate: parseFloat(e.target.value) || 0
}))}
/>
</div>
</div>
<Button onClick={handleUpdateTurnoverConfig}>
Update Turnover Configuration
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,229 @@
import { useState } from 'react';
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import config from '../../config';
interface StockThreshold {
id: number;
category_id: number | null;
vendor: string | null;
critical_days: number;
reorder_days: number;
overstock_days: number;
low_stock_threshold: number;
min_reorder_quantity: number;
}
interface SafetyStockConfig {
id: number;
category_id: number | null;
vendor: string | null;
coverage_days: number;
service_level: number;
}
export function StockManagement() {
const [stockThresholds, setStockThresholds] = useState<StockThreshold>({
id: 1,
category_id: null,
vendor: null,
critical_days: 7,
reorder_days: 14,
overstock_days: 90,
low_stock_threshold: 5,
min_reorder_quantity: 1
});
const [safetyStockConfig, setSafetyStockConfig] = useState<SafetyStockConfig>({
id: 1,
category_id: null,
vendor: null,
coverage_days: 14,
service_level: 95.0
});
const handleUpdateStockThresholds = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/stock-thresholds/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(stockThresholds)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update stock thresholds');
}
toast.success('Stock thresholds updated successfully');
} catch (error) {
toast.error(`Failed to update thresholds: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
const handleUpdateSafetyStockConfig = async () => {
try {
const response = await fetch(`${config.apiUrl}/config/safety-stock/1`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(safetyStockConfig)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.error || 'Failed to update safety stock configuration');
}
toast.success('Safety stock configuration updated successfully');
} catch (error) {
toast.error(`Failed to update configuration: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
return (
<div className="space-y-4">
{/* Stock Thresholds Card */}
<Card>
<CardHeader>
<CardTitle>Stock Thresholds</CardTitle>
<CardDescription>Configure stock level thresholds for inventory management</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="critical-days">Critical Days</Label>
<Input
id="critical-days"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={stockThresholds.critical_days}
onChange={(e) => setStockThresholds(prev => ({
...prev,
critical_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="reorder-days">Reorder Days</Label>
<Input
id="reorder-days"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={stockThresholds.reorder_days}
onChange={(e) => setStockThresholds(prev => ({
...prev,
reorder_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="overstock-days">Overstock Days</Label>
<Input
id="overstock-days"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={stockThresholds.overstock_days}
onChange={(e) => setStockThresholds(prev => ({
...prev,
overstock_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="low-stock-threshold">Low Stock Threshold</Label>
<Input
id="low-stock-threshold"
type="number"
min="0"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={stockThresholds.low_stock_threshold}
onChange={(e) => setStockThresholds(prev => ({
...prev,
low_stock_threshold: parseInt(e.target.value) || 0
}))}
/>
</div>
<div>
<Label htmlFor="min-reorder-quantity">Minimum Reorder Quantity</Label>
<Input
id="min-reorder-quantity"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={stockThresholds.min_reorder_quantity}
onChange={(e) => setStockThresholds(prev => ({
...prev,
min_reorder_quantity: parseInt(e.target.value) || 1
}))}
/>
</div>
</div>
<Button onClick={handleUpdateStockThresholds}>
Update Stock Thresholds
</Button>
</div>
</CardContent>
</Card>
{/* Safety Stock Configuration Card */}
<Card>
<CardHeader>
<CardTitle>Safety Stock</CardTitle>
<CardDescription>Configure safety stock parameters</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="coverage-days">Coverage Days</Label>
<Input
id="coverage-days"
type="number"
min="1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={safetyStockConfig.coverage_days}
onChange={(e) => setSafetyStockConfig(prev => ({
...prev,
coverage_days: parseInt(e.target.value) || 1
}))}
/>
</div>
<div>
<Label htmlFor="service-level">Service Level (%)</Label>
<Input
id="service-level"
type="number"
min="0"
max="100"
step="0.1"
className="[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
value={safetyStockConfig.service_level}
onChange={(e) => setSafetyStockConfig(prev => ({
...prev,
service_level: parseFloat(e.target.value) || 0
}))}
/>
</div>
</div>
<Button onClick={handleUpdateSafetyStockConfig}>
Update Safety Stock Configuration
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,26 +1,39 @@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { DataManagement } from "@/components/settings/DataManagement";
import { Configuration } from "@/components/settings/Configuration";
import { StockManagement } from "@/components/settings/StockManagement";
import { PerformanceMetrics } from "@/components/settings/PerformanceMetrics";
import { CalculationSettings } from "@/components/settings/CalculationSettings";
export function Settings() {
return (
<div className="p-8 space-y-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Settings</h1>
<div className="container mx-auto py-6">
<div className="mb-6">
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground">Manage your inventory system settings and configurations</p>
</div>
<Tabs defaultValue="data" className="w-full">
<Tabs defaultValue="data-management" className="space-y-4">
<TabsList>
<TabsTrigger value="data">Data Management</TabsTrigger>
<TabsTrigger value="config">Configuration</TabsTrigger>
<TabsTrigger value="data-management">Data Management</TabsTrigger>
<TabsTrigger value="stock-management">Stock Management</TabsTrigger>
<TabsTrigger value="performance-metrics">Performance Metrics</TabsTrigger>
<TabsTrigger value="calculation-settings">Calculation Settings</TabsTrigger>
</TabsList>
<TabsContent value="data" className="space-y-4">
<TabsContent value="data-management">
<DataManagement />
</TabsContent>
<TabsContent value="config" className="space-y-4">
<Configuration />
<TabsContent value="stock-management">
<StockManagement />
</TabsContent>
<TabsContent value="performance-metrics">
<PerformanceMetrics />
</TabsContent>
<TabsContent value="calculation-settings">
<CalculationSettings />
</TabsContent>
</Tabs>
</div>