Integrate config tables into existing scripts, add new config tables and settings pages
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
112
inventory/src/components/settings/CalculationSettings.tsx
Normal file
112
inventory/src/components/settings/CalculationSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
295
inventory/src/components/settings/PerformanceMetrics.tsx
Normal file
295
inventory/src/components/settings/PerformanceMetrics.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
229
inventory/src/components/settings/StockManagement.tsx
Normal file
229
inventory/src/components/settings/StockManagement.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user