Add discount simulator
This commit is contained in:
475
inventory-server/dashboard/acot-server/routes/discounts.js
Normal file
475
inventory-server/dashboard/acot-server/routes/discounts.js
Normal file
@@ -0,0 +1,475 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const { DateTime } = require('luxon');
|
||||||
|
const { getDbConnection } = require('../db/connection');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const RANGE_BOUNDS = [
|
||||||
|
10, 20, 30, 40, 50, 60, 70, 80, 90,
|
||||||
|
100, 110, 120, 130, 140, 150, 160, 170, 180, 190, 200,
|
||||||
|
300, 400, 500, 1000, 1500, 2000
|
||||||
|
];
|
||||||
|
|
||||||
|
const FINAL_BUCKET_KEY = 'PLUS';
|
||||||
|
|
||||||
|
function buildRangeDefinitions() {
|
||||||
|
const ranges = [];
|
||||||
|
let previous = 0;
|
||||||
|
for (const bound of RANGE_BOUNDS) {
|
||||||
|
const label = `$${previous.toLocaleString()} - $${bound.toLocaleString()}`;
|
||||||
|
const key = bound.toString().padStart(5, '0');
|
||||||
|
ranges.push({
|
||||||
|
min: previous,
|
||||||
|
max: bound,
|
||||||
|
label,
|
||||||
|
key,
|
||||||
|
sort: bound
|
||||||
|
});
|
||||||
|
previous = bound;
|
||||||
|
}
|
||||||
|
// Remove the 2000+ category - all orders >2000 will go into the 2000 bucket
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_DEFINITIONS = buildRangeDefinitions();
|
||||||
|
|
||||||
|
const BUCKET_CASE = (() => {
|
||||||
|
const parts = [];
|
||||||
|
for (let i = 0; i < RANGE_BOUNDS.length; i++) {
|
||||||
|
const bound = RANGE_BOUNDS[i];
|
||||||
|
const key = bound.toString().padStart(5, '0');
|
||||||
|
if (i === RANGE_BOUNDS.length - 1) {
|
||||||
|
// For the last bucket (2000), include all orders >= 1500 (previous bound)
|
||||||
|
parts.push(`ELSE '${key}'`);
|
||||||
|
} else {
|
||||||
|
parts.push(`WHEN o.summary_subtotal <= ${bound} THEN '${key}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `CASE\n ${parts.join('\n ')}\n END`;
|
||||||
|
})();
|
||||||
|
|
||||||
|
const DEFAULT_POINT_DOLLAR_VALUE = 0.005; // 1000 points = $5
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
merchantFeePercent: 2.9,
|
||||||
|
fixedCostPerOrder: 1.5,
|
||||||
|
pointsPerDollar: 0,
|
||||||
|
pointsRedemptionRate: 0,
|
||||||
|
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDate(value, fallback) {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
const parsed = DateTime.fromISO(value);
|
||||||
|
if (!parsed.isValid) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateForSql(dt) {
|
||||||
|
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMidpoint(range) {
|
||||||
|
if (range.max == null) {
|
||||||
|
return range.min + 200; // Rough estimate for 2000+
|
||||||
|
}
|
||||||
|
return (range.min + range.max) / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/promos', async (req, res) => {
|
||||||
|
let connection;
|
||||||
|
try {
|
||||||
|
const { connection: conn, release } = await getDbConnection();
|
||||||
|
connection = conn;
|
||||||
|
const releaseConnection = release;
|
||||||
|
|
||||||
|
const sql = `
|
||||||
|
SELECT
|
||||||
|
p.promo_id AS id,
|
||||||
|
p.promo_code AS code,
|
||||||
|
p.promo_description_online AS description_online,
|
||||||
|
p.promo_description_private AS description_private,
|
||||||
|
p.date_start,
|
||||||
|
p.date_end,
|
||||||
|
COALESCE(u.usage_count, 0) AS usage_count
|
||||||
|
FROM promos p
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT
|
||||||
|
discount_code,
|
||||||
|
COUNT(DISTINCT order_id) AS usage_count
|
||||||
|
FROM order_discounts
|
||||||
|
WHERE discount_type = 10 AND discount_active = 1
|
||||||
|
GROUP BY discount_code
|
||||||
|
) u ON u.discount_code = p.promo_id
|
||||||
|
WHERE p.date_end >= DATE_SUB(CURDATE(), INTERVAL 3 YEAR)
|
||||||
|
ORDER BY p.date_end DESC, p.date_start DESC
|
||||||
|
LIMIT 200
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await connection.execute(sql);
|
||||||
|
releaseConnection();
|
||||||
|
|
||||||
|
const promos = rows.map(row => ({
|
||||||
|
id: Number(row.id),
|
||||||
|
code: row.code,
|
||||||
|
description: row.description_online || row.description_private || '',
|
||||||
|
dateStart: row.date_start,
|
||||||
|
dateEnd: row.date_end,
|
||||||
|
usageCount: Number(row.usage_count || 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ promos });
|
||||||
|
} catch (error) {
|
||||||
|
if (connection) {
|
||||||
|
try {
|
||||||
|
connection.destroy();
|
||||||
|
} catch (destroyError) {
|
||||||
|
console.error('Failed to destroy connection after error:', destroyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.error('Error fetching promos:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch promos' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/simulate', async (req, res) => {
|
||||||
|
const {
|
||||||
|
dateRange = {},
|
||||||
|
filters = {},
|
||||||
|
productPromo = {},
|
||||||
|
shippingPromo = {},
|
||||||
|
shippingTiers = [],
|
||||||
|
merchantFeePercent,
|
||||||
|
fixedCostPerOrder,
|
||||||
|
pointsConfig = {}
|
||||||
|
} = req.body || {};
|
||||||
|
|
||||||
|
const endDefault = DateTime.now();
|
||||||
|
const startDefault = endDefault.minus({ months: 6 });
|
||||||
|
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
|
||||||
|
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
|
||||||
|
|
||||||
|
const shipCountry = filters.shipCountry || 'US';
|
||||||
|
const promoIds = Array.isArray(filters.promoIds) ? filters.promoIds.filter(Boolean) : [];
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
merchantFeePercent: typeof merchantFeePercent === 'number' ? merchantFeePercent : DEFAULTS.merchantFeePercent,
|
||||||
|
fixedCostPerOrder: typeof fixedCostPerOrder === 'number' ? fixedCostPerOrder : DEFAULTS.fixedCostPerOrder,
|
||||||
|
productPromo: {
|
||||||
|
type: productPromo.type || 'none',
|
||||||
|
value: Number(productPromo.value || 0),
|
||||||
|
minSubtotal: Number(productPromo.minSubtotal || 0)
|
||||||
|
},
|
||||||
|
shippingPromo: {
|
||||||
|
type: shippingPromo.type || 'none',
|
||||||
|
value: Number(shippingPromo.value || 0),
|
||||||
|
minSubtotal: Number(shippingPromo.minSubtotal || 0),
|
||||||
|
maxDiscount: Number(shippingPromo.maxDiscount || 0)
|
||||||
|
},
|
||||||
|
shippingTiers: Array.isArray(shippingTiers)
|
||||||
|
? shippingTiers
|
||||||
|
.map(tier => ({
|
||||||
|
threshold: Number(tier.threshold || 0),
|
||||||
|
mode: tier.mode === 'percentage' || tier.mode === 'flat' ? tier.mode : 'percentage',
|
||||||
|
value: Number(tier.value || 0)
|
||||||
|
}))
|
||||||
|
.filter(tier => tier.threshold >= 0 && tier.value >= 0)
|
||||||
|
.sort((a, b) => a.threshold - b.threshold)
|
||||||
|
: [],
|
||||||
|
points: {
|
||||||
|
pointsPerDollar: typeof pointsConfig.pointsPerDollar === 'number' ? pointsConfig.pointsPerDollar : null,
|
||||||
|
redemptionRate: typeof pointsConfig.redemptionRate === 'number' ? pointsConfig.redemptionRate : null,
|
||||||
|
pointDollarValue: typeof pointsConfig.pointDollarValue === 'number'
|
||||||
|
? pointsConfig.pointDollarValue
|
||||||
|
: DEFAULT_POINT_DOLLAR_VALUE
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let connection;
|
||||||
|
let release;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dbConn = await getDbConnection();
|
||||||
|
connection = dbConn.connection;
|
||||||
|
release = dbConn.release;
|
||||||
|
|
||||||
|
const params = [
|
||||||
|
shipCountry,
|
||||||
|
formatDateForSql(startDt),
|
||||||
|
formatDateForSql(endDt)
|
||||||
|
];
|
||||||
|
const promoJoin = promoIds.length > 0
|
||||||
|
? 'JOIN order_discounts od ON od.order_id = o.order_id AND od.discount_active = 1 AND od.discount_type = 10'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (promoIds.length > 0) {
|
||||||
|
params.push(promoIds);
|
||||||
|
}
|
||||||
|
params.push(formatDateForSql(startDt), formatDateForSql(endDt));
|
||||||
|
|
||||||
|
const filteredOrdersQuery = `
|
||||||
|
SELECT
|
||||||
|
o.order_id,
|
||||||
|
o.summary_subtotal,
|
||||||
|
o.summary_discount_subtotal,
|
||||||
|
o.summary_shipping,
|
||||||
|
o.ship_method_rate,
|
||||||
|
o.ship_method_cost,
|
||||||
|
o.summary_points,
|
||||||
|
${BUCKET_CASE} AS bucket_key
|
||||||
|
FROM _order o
|
||||||
|
${promoJoin}
|
||||||
|
WHERE o.summary_total > 0
|
||||||
|
AND o.summary_subtotal > 0
|
||||||
|
AND o.order_status NOT IN (15)
|
||||||
|
AND o.ship_method_selected <> 'holdit'
|
||||||
|
AND o.ship_country = ?
|
||||||
|
AND o.date_placed BETWEEN ? AND ?
|
||||||
|
${promoIds.length > 0 ? 'AND od.discount_code IN (?)' : ''}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const bucketQuery = `
|
||||||
|
SELECT
|
||||||
|
f.bucket_key,
|
||||||
|
COUNT(*) AS order_count,
|
||||||
|
SUM(f.summary_subtotal) AS subtotal_sum,
|
||||||
|
SUM(f.summary_discount_subtotal) AS product_discount_sum,
|
||||||
|
SUM(f.summary_subtotal + f.summary_discount_subtotal) AS regular_subtotal_sum,
|
||||||
|
SUM(f.ship_method_rate) AS ship_rate_sum,
|
||||||
|
SUM(f.ship_method_cost) AS ship_cost_sum,
|
||||||
|
SUM(f.summary_points) AS points_awarded_sum,
|
||||||
|
SUM(COALESCE(p.points_redeemed, 0)) AS points_redeemed_sum,
|
||||||
|
SUM(COALESCE(c.total_cogs, 0)) AS cogs_sum,
|
||||||
|
AVG(f.summary_subtotal) AS avg_subtotal,
|
||||||
|
AVG(f.summary_discount_subtotal) AS avg_product_discount,
|
||||||
|
AVG(f.ship_method_rate) AS avg_ship_rate,
|
||||||
|
AVG(f.ship_method_cost) AS avg_ship_cost,
|
||||||
|
AVG(COALESCE(c.total_cogs, 0)) AS avg_cogs
|
||||||
|
FROM (
|
||||||
|
${filteredOrdersQuery}
|
||||||
|
) AS f
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT order_id, SUM(cogs_amount) AS total_cogs
|
||||||
|
FROM report_sales_data
|
||||||
|
WHERE action IN (1,2,3)
|
||||||
|
AND date_change BETWEEN ? AND ?
|
||||||
|
GROUP BY order_id
|
||||||
|
) AS c ON c.order_id = f.order_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT order_id, SUM(discount_amount_points) AS points_redeemed
|
||||||
|
FROM order_discounts
|
||||||
|
WHERE discount_type = 20 AND discount_active = 1
|
||||||
|
GROUP BY order_id
|
||||||
|
) AS p ON p.order_id = f.order_id
|
||||||
|
GROUP BY f.bucket_key
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [rows] = await connection.execute(bucketQuery, params);
|
||||||
|
|
||||||
|
const totals = {
|
||||||
|
orders: 0,
|
||||||
|
subtotal: 0,
|
||||||
|
productDiscount: 0,
|
||||||
|
regularSubtotal: 0,
|
||||||
|
shipRate: 0,
|
||||||
|
shipCost: 0,
|
||||||
|
cogs: 0,
|
||||||
|
pointsAwarded: 0,
|
||||||
|
pointsRedeemed: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const rowMap = new Map();
|
||||||
|
for (const row of rows) {
|
||||||
|
const key = row.bucket_key || FINAL_BUCKET_KEY;
|
||||||
|
const parsed = {
|
||||||
|
orderCount: Number(row.order_count || 0),
|
||||||
|
subtotalSum: Number(row.subtotal_sum || 0),
|
||||||
|
productDiscountSum: Number(row.product_discount_sum || 0),
|
||||||
|
regularSubtotalSum: Number(row.regular_subtotal_sum || 0),
|
||||||
|
shipRateSum: Number(row.ship_rate_sum || 0),
|
||||||
|
shipCostSum: Number(row.ship_cost_sum || 0),
|
||||||
|
pointsAwardedSum: Number(row.points_awarded_sum || 0),
|
||||||
|
pointsRedeemedSum: Number(row.points_redeemed_sum || 0),
|
||||||
|
cogsSum: Number(row.cogs_sum || 0),
|
||||||
|
avgSubtotal: Number(row.avg_subtotal || 0),
|
||||||
|
avgProductDiscount: Number(row.avg_product_discount || 0),
|
||||||
|
avgShipRate: Number(row.avg_ship_rate || 0),
|
||||||
|
avgShipCost: Number(row.avg_ship_cost || 0),
|
||||||
|
avgCogs: Number(row.avg_cogs || 0)
|
||||||
|
};
|
||||||
|
rowMap.set(key, parsed);
|
||||||
|
|
||||||
|
totals.orders += parsed.orderCount;
|
||||||
|
totals.subtotal += parsed.subtotalSum;
|
||||||
|
totals.productDiscount += parsed.productDiscountSum;
|
||||||
|
totals.regularSubtotal += parsed.regularSubtotalSum;
|
||||||
|
totals.shipRate += parsed.shipRateSum;
|
||||||
|
totals.shipCost += parsed.shipCostSum;
|
||||||
|
totals.cogs += parsed.cogsSum;
|
||||||
|
totals.pointsAwarded += parsed.pointsAwardedSum;
|
||||||
|
totals.pointsRedeemed += parsed.pointsRedeemedSum;
|
||||||
|
}
|
||||||
|
|
||||||
|
const productDiscountRate = totals.regularSubtotal > 0
|
||||||
|
? totals.productDiscount / totals.regularSubtotal
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const pointsPerDollar = config.points.pointsPerDollar != null
|
||||||
|
? config.points.pointsPerDollar
|
||||||
|
: totals.subtotal > 0
|
||||||
|
? totals.pointsAwarded / totals.subtotal
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const redemptionRate = config.points.redemptionRate != null
|
||||||
|
? config.points.redemptionRate
|
||||||
|
: totals.pointsAwarded > 0
|
||||||
|
? Math.min(1, totals.pointsRedeemed / totals.pointsAwarded)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const pointDollarValue = config.points.pointDollarValue || DEFAULT_POINT_DOLLAR_VALUE;
|
||||||
|
|
||||||
|
const bucketResults = [];
|
||||||
|
let weightedProfitAmount = 0;
|
||||||
|
let weightedProfitPercent = 0;
|
||||||
|
|
||||||
|
for (const range of RANGE_DEFINITIONS) {
|
||||||
|
const data = rowMap.get(range.key) || {
|
||||||
|
orderCount: 0,
|
||||||
|
avgSubtotal: 0,
|
||||||
|
avgShipRate: 0,
|
||||||
|
avgShipCost: 0,
|
||||||
|
avgCogs: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
const orderValue = data.avgSubtotal > 0 ? data.avgSubtotal : getMidpoint(range);
|
||||||
|
const shippingChargeBase = data.avgShipRate > 0 ? data.avgShipRate : 0;
|
||||||
|
const actualShippingCost = data.avgShipCost > 0 ? data.avgShipCost : 0;
|
||||||
|
const productCogs = data.avgCogs > 0 ? data.avgCogs : 0;
|
||||||
|
const productDiscountAmount = orderValue * productDiscountRate;
|
||||||
|
const effectiveRegularPrice = productDiscountRate < 0.99
|
||||||
|
? orderValue / (1 - productDiscountRate)
|
||||||
|
: orderValue;
|
||||||
|
|
||||||
|
let promoProductDiscount = 0;
|
||||||
|
if (config.productPromo.type === 'percentage_subtotal' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
promoProductDiscount = Math.min(orderValue, (config.productPromo.value / 100) * orderValue);
|
||||||
|
} else if (config.productPromo.type === 'percentage_regular' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
const targetRate = config.productPromo.value / 100;
|
||||||
|
const additionalRate = Math.max(0, targetRate - productDiscountRate);
|
||||||
|
promoProductDiscount = Math.min(orderValue, additionalRate * effectiveRegularPrice);
|
||||||
|
} else if (config.productPromo.type === 'fixed_amount' && orderValue >= config.productPromo.minSubtotal) {
|
||||||
|
promoProductDiscount = Math.min(orderValue, config.productPromo.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
let shippingAfterAuto = shippingChargeBase;
|
||||||
|
for (const tier of config.shippingTiers) {
|
||||||
|
if (orderValue >= tier.threshold) {
|
||||||
|
if (tier.mode === 'percentage') {
|
||||||
|
shippingAfterAuto = shippingChargeBase * Math.max(0, 1 - tier.value / 100);
|
||||||
|
} else if (tier.mode === 'flat') {
|
||||||
|
shippingAfterAuto = tier.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let shipPromoDiscount = 0;
|
||||||
|
if (config.shippingPromo.type !== 'none' && orderValue >= config.shippingPromo.minSubtotal) {
|
||||||
|
if (config.shippingPromo.type === 'percentage') {
|
||||||
|
shipPromoDiscount = shippingAfterAuto * (config.shippingPromo.value / 100);
|
||||||
|
} else if (config.shippingPromo.type === 'fixed') {
|
||||||
|
shipPromoDiscount = config.shippingPromo.value;
|
||||||
|
}
|
||||||
|
if (config.shippingPromo.maxDiscount > 0) {
|
||||||
|
shipPromoDiscount = Math.min(shipPromoDiscount, config.shippingPromo.maxDiscount);
|
||||||
|
}
|
||||||
|
shipPromoDiscount = Math.min(shipPromoDiscount, shippingAfterAuto);
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerShipCost = Math.max(0, shippingAfterAuto - shipPromoDiscount);
|
||||||
|
const customerItemCost = Math.max(0, orderValue - promoProductDiscount);
|
||||||
|
const totalRevenue = customerItemCost + customerShipCost;
|
||||||
|
|
||||||
|
const merchantFees = totalRevenue * (config.merchantFeePercent / 100);
|
||||||
|
const pointsCost = customerItemCost * pointsPerDollar * redemptionRate * pointDollarValue;
|
||||||
|
const fixedCosts = config.fixedCostPerOrder;
|
||||||
|
const totalCosts = productCogs + actualShippingCost + merchantFees + pointsCost + fixedCosts;
|
||||||
|
const profit = totalRevenue - totalCosts;
|
||||||
|
const profitPercent = totalRevenue > 0 ? (profit / totalRevenue) : 0;
|
||||||
|
const weight = totals.orders > 0 ? (data.orderCount || 0) / totals.orders : 0;
|
||||||
|
|
||||||
|
weightedProfitAmount += profit * weight;
|
||||||
|
weightedProfitPercent += profitPercent * weight;
|
||||||
|
|
||||||
|
bucketResults.push({
|
||||||
|
key: range.key,
|
||||||
|
label: range.label,
|
||||||
|
min: range.min,
|
||||||
|
max: range.max,
|
||||||
|
orderCount: data.orderCount || 0,
|
||||||
|
weight,
|
||||||
|
orderValue,
|
||||||
|
productDiscountAmount,
|
||||||
|
promoProductDiscount,
|
||||||
|
customerItemCost,
|
||||||
|
shippingChargeBase,
|
||||||
|
shippingAfterAuto,
|
||||||
|
shipPromoDiscount,
|
||||||
|
customerShipCost,
|
||||||
|
actualShippingCost,
|
||||||
|
totalRevenue,
|
||||||
|
productCogs,
|
||||||
|
merchantFees,
|
||||||
|
pointsCost,
|
||||||
|
fixedCosts,
|
||||||
|
totalCosts,
|
||||||
|
profit,
|
||||||
|
profitPercent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (release) {
|
||||||
|
release();
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
dateRange: {
|
||||||
|
start: startDt.toISO(),
|
||||||
|
end: endDt.toISO()
|
||||||
|
},
|
||||||
|
totals: {
|
||||||
|
orders: totals.orders,
|
||||||
|
subtotal: totals.subtotal,
|
||||||
|
productDiscountRate,
|
||||||
|
pointsPerDollar,
|
||||||
|
redemptionRate,
|
||||||
|
pointDollarValue,
|
||||||
|
weightedProfitAmount,
|
||||||
|
weightedProfitPercent
|
||||||
|
},
|
||||||
|
buckets: bucketResults
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (release) {
|
||||||
|
try {
|
||||||
|
release();
|
||||||
|
} catch (releaseError) {
|
||||||
|
console.error('Failed to release connection after error:', releaseError);
|
||||||
|
}
|
||||||
|
} else if (connection) {
|
||||||
|
try {
|
||||||
|
connection.destroy();
|
||||||
|
} catch (destroyError) {
|
||||||
|
console.error('Failed to destroy connection after error:', destroyError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Error running discount simulation:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to run discount simulation' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -48,6 +48,7 @@ app.get('/health', (req, res) => {
|
|||||||
// Routes
|
// Routes
|
||||||
app.use('/api/acot/test', require('./routes/test'));
|
app.use('/api/acot/test', require('./routes/test'));
|
||||||
app.use('/api/acot/events', require('./routes/events'));
|
app.use('/api/acot/events', require('./routes/events'));
|
||||||
|
app.use('/api/acot/discounts', require('./routes/discounts'));
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ const Overview = lazy(() => import('./pages/Overview'));
|
|||||||
const Products = lazy(() => import('./pages/Products').then(module => ({ default: module.Products })));
|
const Products = lazy(() => import('./pages/Products').then(module => ({ default: module.Products })));
|
||||||
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
const Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
||||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||||
|
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
||||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||||
const Categories = lazy(() => import('./pages/Categories'));
|
const Categories = lazy(() => import('./pages/Categories'));
|
||||||
const Brands = lazy(() => import('./pages/Brands'));
|
const Brands = lazy(() => import('./pages/Brands'));
|
||||||
@@ -151,6 +152,13 @@ function App() {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</Protected>
|
</Protected>
|
||||||
} />
|
} />
|
||||||
|
<Route path="/discount-simulator" element={
|
||||||
|
<Protected page="discount_simulator">
|
||||||
|
<Suspense fallback={<PageLoading />}>
|
||||||
|
<DiscountSimulator />
|
||||||
|
</Suspense>
|
||||||
|
</Protected>
|
||||||
|
} />
|
||||||
<Route path="/forecasting" element={
|
<Route path="/forecasting" element={
|
||||||
<Protected page="forecasting">
|
<Protected page="forecasting">
|
||||||
<Suspense fallback={<PageLoading />}>
|
<Suspense fallback={<PageLoading />}>
|
||||||
@@ -202,4 +210,3 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
export default App;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const PAGES = [
|
|||||||
{ path: "/vendors", permission: "access:vendors" },
|
{ path: "/vendors", permission: "access:vendors" },
|
||||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||||
{ path: "/analytics", permission: "access:analytics" },
|
{ path: "/analytics", permission: "access:analytics" },
|
||||||
|
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
||||||
{ path: "/forecasting", permission: "access:forecasting" },
|
{ path: "/forecasting", permission: "access:forecasting" },
|
||||||
{ path: "/import", permission: "access:import" },
|
{ path: "/import", permission: "access:import" },
|
||||||
{ path: "/settings", permission: "access:settings" },
|
{ path: "/settings", permission: "access:settings" },
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ Admin users automatically have all permissions.
|
|||||||
| `access:vendors` | Access to Vendors page |
|
| `access:vendors` | Access to Vendors page |
|
||||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||||
| `access:analytics` | Access to Analytics page |
|
| `access:analytics` | Access to Analytics page |
|
||||||
|
| `access:discount_simulator` | Access to Discount Simulator page |
|
||||||
| `access:forecasting` | Access to Forecasting page |
|
| `access:forecasting` | Access to Forecasting page |
|
||||||
| `access:import` | Access to Import page |
|
| `access:import` | Access to Import page |
|
||||||
| `access:settings` | Access to Settings page |
|
| `access:settings` | Access to Settings page |
|
||||||
|
|||||||
474
inventory/src/components/discount-simulator/ConfigPanel.tsx
Normal file
474
inventory/src/components/discount-simulator/ConfigPanel.tsx
Normal file
@@ -0,0 +1,474 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { DateRange } from "react-day-picker";
|
||||||
|
import { DateRangePicker } from "@/components/ui/date-range-picker";
|
||||||
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig } from "@/types/discount-simulator";
|
||||||
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
|
||||||
|
interface ConfigPanelProps {
|
||||||
|
dateRange: DateRange;
|
||||||
|
onDateRangeChange: (range: DateRange | undefined) => void;
|
||||||
|
promos: DiscountPromoOption[];
|
||||||
|
selectedPromoId?: number;
|
||||||
|
onPromoChange: (promoId: number | undefined) => void;
|
||||||
|
productPromo: {
|
||||||
|
type: DiscountPromoType;
|
||||||
|
value: number;
|
||||||
|
minSubtotal: number;
|
||||||
|
};
|
||||||
|
onProductPromoChange: (update: Partial<ConfigPanelProps["productPromo"]>) => void;
|
||||||
|
shippingPromo: {
|
||||||
|
type: ShippingPromoType;
|
||||||
|
value: number;
|
||||||
|
minSubtotal: number;
|
||||||
|
maxDiscount: number;
|
||||||
|
};
|
||||||
|
onShippingPromoChange: (update: Partial<ConfigPanelProps["shippingPromo"]>) => void;
|
||||||
|
shippingTiers: ShippingTierConfig[];
|
||||||
|
onShippingTiersChange: (tiers: ShippingTierConfig[]) => void;
|
||||||
|
merchantFeePercent: number;
|
||||||
|
onMerchantFeeChange: (value: number) => void;
|
||||||
|
fixedCostPerOrder: number;
|
||||||
|
onFixedCostChange: (value: number) => void;
|
||||||
|
pointsPerDollar: number;
|
||||||
|
redemptionRate: number;
|
||||||
|
pointDollarValue: number;
|
||||||
|
onPointsChange: (update: {
|
||||||
|
pointsPerDollar?: number;
|
||||||
|
redemptionRate?: number;
|
||||||
|
pointDollarValue?: number;
|
||||||
|
}) => void;
|
||||||
|
onRunSimulation: () => void;
|
||||||
|
isRunning: boolean;
|
||||||
|
recommendedPoints?: {
|
||||||
|
pointsPerDollar: number;
|
||||||
|
redemptionRate: number;
|
||||||
|
pointDollarValue: number;
|
||||||
|
};
|
||||||
|
onApplyRecommendedPoints?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(value: string, fallback = 0) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigPanel({
|
||||||
|
dateRange,
|
||||||
|
onDateRangeChange,
|
||||||
|
promos,
|
||||||
|
selectedPromoId,
|
||||||
|
onPromoChange,
|
||||||
|
productPromo,
|
||||||
|
onProductPromoChange,
|
||||||
|
shippingPromo,
|
||||||
|
onShippingPromoChange,
|
||||||
|
shippingTiers,
|
||||||
|
onShippingTiersChange,
|
||||||
|
merchantFeePercent,
|
||||||
|
onMerchantFeeChange,
|
||||||
|
fixedCostPerOrder,
|
||||||
|
onFixedCostChange,
|
||||||
|
pointsPerDollar,
|
||||||
|
redemptionRate,
|
||||||
|
pointDollarValue,
|
||||||
|
onPointsChange,
|
||||||
|
onRunSimulation,
|
||||||
|
isRunning,
|
||||||
|
recommendedPoints,
|
||||||
|
onApplyRecommendedPoints
|
||||||
|
}: ConfigPanelProps) {
|
||||||
|
const promoOptions = useMemo(() => {
|
||||||
|
return promos.map((promo) => ({
|
||||||
|
value: promo.id.toString(),
|
||||||
|
label: promo.description || promo.code,
|
||||||
|
description: promo.description,
|
||||||
|
}));
|
||||||
|
}, [promos]);
|
||||||
|
|
||||||
|
const handleTierUpdate = (index: number, update: Partial<ShippingTierConfig>) => {
|
||||||
|
const tiers = [...shippingTiers];
|
||||||
|
tiers[index] = {
|
||||||
|
...tiers[index],
|
||||||
|
...update,
|
||||||
|
};
|
||||||
|
const sorted = tiers
|
||||||
|
.filter((tier) => tier != null)
|
||||||
|
.map((tier) => ({
|
||||||
|
...tier,
|
||||||
|
threshold: Number.isFinite(tier.threshold) ? tier.threshold : 0,
|
||||||
|
value: Number.isFinite(tier.value) ? tier.value : 0,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.threshold - b.threshold);
|
||||||
|
onShippingTiersChange(sorted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTierRemove = (index: number) => {
|
||||||
|
const tiers = shippingTiers.filter((_, i) => i !== index);
|
||||||
|
onShippingTiersChange(tiers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTierAdd = () => {
|
||||||
|
const lastThreshold = shippingTiers[shippingTiers.length - 1]?.threshold ?? 0;
|
||||||
|
const tiers = [
|
||||||
|
...shippingTiers,
|
||||||
|
{
|
||||||
|
threshold: lastThreshold,
|
||||||
|
mode: "percentage" as const,
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
onShippingTiersChange(tiers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const recommendedDiffers = recommendedPoints
|
||||||
|
? recommendedPoints.pointsPerDollar !== pointsPerDollar ||
|
||||||
|
recommendedPoints.redemptionRate !== redemptionRate ||
|
||||||
|
recommendedPoints.pointDollarValue !== pointDollarValue
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const fieldClass = "flex flex-col gap-1.5";
|
||||||
|
const labelClass = "text-[0.65rem] uppercase tracking-wide text-muted-foreground";
|
||||||
|
const sectionTitleClass = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||||
|
const sectionClass = "flex flex-col gap-3";
|
||||||
|
const fieldRowClass = "flex flex-col gap-3";
|
||||||
|
const fieldRowHorizontalClass = "flex gap-3";
|
||||||
|
const compactTriggerClass = "h-8 px-2 text-xs";
|
||||||
|
const compactNumberClass = "h-8 px-2 text-sm";
|
||||||
|
const compactWideNumberClass = "h-8 px-2 text-sm";
|
||||||
|
const showProductAdjustments = productPromo.type !== "none";
|
||||||
|
const showShippingAdjustments = shippingPromo.type !== "none";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="w-full">
|
||||||
|
<CardContent className="flex flex-col gap-4 px-4 py-4">
|
||||||
|
<div className="space-y-6">
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<span className={sectionTitleClass}>Filters</span>
|
||||||
|
<div className={fieldRowClass}>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Date range</Label>
|
||||||
|
<DateRangePicker
|
||||||
|
value={dateRange}
|
||||||
|
onChange={(range) => onDateRangeChange(range)}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Promo code</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedPromoId !== undefined ? selectedPromoId.toString() : undefined}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === '__all__') {
|
||||||
|
onPromoChange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
|
<SelectValue placeholder="All promos" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="max-h-56">
|
||||||
|
<SelectItem value="__all__">All promos</SelectItem>
|
||||||
|
{promoOptions.map((promo) => (
|
||||||
|
<SelectItem key={promo.value} value={promo.value}>
|
||||||
|
<div className="flex flex-col text-xs">
|
||||||
|
<span>{promo.label}</span>
|
||||||
|
{promo.description && (
|
||||||
|
<span className="text-muted-foreground">{promo.description}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<Separator />
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<span className={sectionTitleClass}>Product promo</span>
|
||||||
|
<div className={fieldRowClass}>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Promo type</Label>
|
||||||
|
<Select
|
||||||
|
value={productPromo.type}
|
||||||
|
onValueChange={(value) => onProductPromoChange({ type: value as DiscountPromoType })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No additional promo</SelectItem>
|
||||||
|
<SelectItem value="percentage_subtotal">% off subtotal</SelectItem>
|
||||||
|
<SelectItem value="percentage_regular">% off regular price</SelectItem>
|
||||||
|
<SelectItem value="fixed_amount">Fixed dollar discount</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{showProductAdjustments && (
|
||||||
|
<div className={fieldRowHorizontalClass}>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>
|
||||||
|
{productPromo.type.startsWith("percentage") ? "Percent" : "Amount"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={Math.round(productPromo.value ?? 0)}
|
||||||
|
onChange={(event) => onProductPromoChange({ value: parseNumber(event.target.value, 0) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Min subtotal</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={Math.round(productPromo.minSubtotal ?? 0)}
|
||||||
|
onChange={(event) => onProductPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<span className={sectionTitleClass}>Shipping promo</span>
|
||||||
|
<div className={fieldRowClass}>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Promo type</Label>
|
||||||
|
<Select
|
||||||
|
value={shippingPromo.type}
|
||||||
|
onValueChange={(value) => onShippingPromoChange({ type: value as ShippingPromoType })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="none">No shipping promo</SelectItem>
|
||||||
|
<SelectItem value="percentage">% off shipping charge</SelectItem>
|
||||||
|
<SelectItem value="fixed">Fixed dollar discount</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{showShippingAdjustments && (
|
||||||
|
<>
|
||||||
|
<div className={fieldRowHorizontalClass}>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>
|
||||||
|
{shippingPromo.type === "percentage" ? "Percent" : "Amount"}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={Math.round(shippingPromo.value ?? 0)}
|
||||||
|
onChange={(event) => onShippingPromoChange({ value: parseNumber(event.target.value, 0) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Min subtotal</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={Math.round(shippingPromo.minSubtotal ?? 0)}
|
||||||
|
onChange={(event) => onShippingPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Max discount</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={Math.round(shippingPromo.maxDiscount ?? 0)}
|
||||||
|
onChange={(event) => onShippingPromoChange({ maxDiscount: parseNumber(event.target.value, 0) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={sectionTitleClass}>Shipping tiers</span>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleTierAdd}>
|
||||||
|
Add tier
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{shippingTiers.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Add tiers to model automatic shipping discounts.</p>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-32">
|
||||||
|
<div className="flex flex-col gap-2 pr-1">
|
||||||
|
{shippingTiers.map((tier, index) => (
|
||||||
|
<div
|
||||||
|
key={`${tier.threshold}-${index}`}
|
||||||
|
className="grid gap-2 rounded border px-2 py-2 text-xs sm:grid-cols-[repeat(3,minmax(0,1fr))_auto] sm:items-center"
|
||||||
|
>
|
||||||
|
<span className="text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground sm:col-span-4">
|
||||||
|
Tier {index + 1}
|
||||||
|
</span>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Threshold ($)</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={tier.threshold}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleTierUpdate(index, {
|
||||||
|
threshold: parseNumber(event.target.value, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Mode</Label>
|
||||||
|
<Select
|
||||||
|
value={tier.mode}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
handleTierUpdate(index, { mode: value as ShippingTierConfig["mode"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="percentage">% off shipping</SelectItem>
|
||||||
|
<SelectItem value="flat">Flat rate</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>{tier.mode === "flat" ? "Flat rate" : "Percent"}</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="1"
|
||||||
|
value={Math.round(tier.value)}
|
||||||
|
onChange={(event) =>
|
||||||
|
handleTierUpdate(index, { value: parseNumber(event.target.value, 0) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end sm:col-span-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleTierRemove(index)}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<span className={sectionTitleClass}>Order costs</span>
|
||||||
|
<div className={fieldRowHorizontalClass}>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Merchant fee (%)</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={merchantFeePercent}
|
||||||
|
onChange={(event) => onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Fixed cost/order ($)</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={fixedCostPerOrder}
|
||||||
|
onChange={(event) => onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={sectionClass}>
|
||||||
|
<span className={sectionTitleClass}>Rewards points</span>
|
||||||
|
<div className={fieldRowClass}>
|
||||||
|
<div className={fieldRowHorizontalClass}>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Points per $</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={pointsPerDollar}
|
||||||
|
onChange={(event) =>
|
||||||
|
onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Redemption rate (%)</Label>
|
||||||
|
<Input
|
||||||
|
className={compactNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
value={redemptionRate * 100}
|
||||||
|
onChange={(event) =>
|
||||||
|
onPointsChange({ redemptionRate: parseNumber(event.target.value, redemptionRate * 100) / 100 })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={fieldClass}>
|
||||||
|
<Label className={labelClass}>Point value ($)</Label>
|
||||||
|
<Input
|
||||||
|
className={compactWideNumberClass}
|
||||||
|
type="number"
|
||||||
|
step="0.0001"
|
||||||
|
value={pointDollarValue}
|
||||||
|
onChange={(event) =>
|
||||||
|
onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{recommendedPoints && (
|
||||||
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
|
{recommendedDiffers && onApplyRecommendedPoints && (
|
||||||
|
<Button variant="outline" size="sm" onClick={onApplyRecommendedPoints}>
|
||||||
|
Use recommended
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<span>
|
||||||
|
Recommended: {recommendedPoints.pointsPerDollar.toFixed(4)} pts/$ · {(recommendedPoints.redemptionRate * 100).toFixed(2)}% redeemed · ${recommendedPoints.pointDollarValue.toFixed(4)} per point
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" className="sm:w-auto" onClick={onRunSimulation} disabled={isRunning}>
|
||||||
|
{isRunning ? "Running simulation..." : "Run simulation"}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
207
inventory/src/components/discount-simulator/ResultsChart.tsx
Normal file
207
inventory/src/components/discount-simulator/ResultsChart.tsx
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { DiscountSimulationBucket } from "@/types/discount-simulator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Tooltip,
|
||||||
|
TooltipItem,
|
||||||
|
} from "chart.js";
|
||||||
|
import { Line } from "react-chartjs-2";
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip);
|
||||||
|
|
||||||
|
// Utility function to interpolate between two colors
|
||||||
|
const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => {
|
||||||
|
return [
|
||||||
|
Math.round(color1[0] + (color2[0] - color1[0]) * ratio),
|
||||||
|
Math.round(color1[1] + (color2[1] - color1[1]) * ratio),
|
||||||
|
Math.round(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to calculate color based on profit percentage
|
||||||
|
const getProfitPercentageColor = (percentage: number): string => {
|
||||||
|
const percent = percentage * 100; // Convert to percentage
|
||||||
|
|
||||||
|
// Define color points (RGB values for red-400, yellow-400, green-400)
|
||||||
|
const red: [number, number, number] = [248, 113, 113]; // red-400
|
||||||
|
const yellow: [number, number, number] = [251, 191, 36]; // yellow-400
|
||||||
|
const green: [number, number, number] = [74, 222, 128]; // green-400
|
||||||
|
|
||||||
|
const min = 25;
|
||||||
|
const mid = 29;
|
||||||
|
const max = 35;
|
||||||
|
|
||||||
|
let rgb: [number, number, number];
|
||||||
|
|
||||||
|
if (percent <= min) {
|
||||||
|
rgb = red;
|
||||||
|
} else if (percent >= max) {
|
||||||
|
rgb = green;
|
||||||
|
} else if (percent <= mid) {
|
||||||
|
// Interpolate between red and yellow
|
||||||
|
const ratio = (percent - min) / (mid - min);
|
||||||
|
rgb = interpolateColor(red, yellow, ratio);
|
||||||
|
} else {
|
||||||
|
// Interpolate between yellow and green
|
||||||
|
const ratio = (percent - mid) / (max - mid);
|
||||||
|
rgb = interpolateColor(yellow, green, ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResultsChartProps {
|
||||||
|
buckets: DiscountSimulationBucket[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (!buckets || buckets.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the same format as the table - show top range value
|
||||||
|
const labels = buckets.map((bucket) => {
|
||||||
|
if (bucket.max == null) {
|
||||||
|
return `$${bucket.min.toLocaleString()}+`;
|
||||||
|
}
|
||||||
|
return `$${bucket.max.toLocaleString()}`;
|
||||||
|
});
|
||||||
|
const profitPercentages = buckets.map((bucket) => Number((bucket.profitPercent * 100).toFixed(2)));
|
||||||
|
|
||||||
|
// Generate colors for each point based on profit percentage
|
||||||
|
const pointColors = buckets.map((bucket) => getProfitPercentageColor(bucket.profitPercent));
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Profit %',
|
||||||
|
data: profitPercentages,
|
||||||
|
borderColor: (context: any) => {
|
||||||
|
const chart = context.chart;
|
||||||
|
const {ctx, chartArea} = chart;
|
||||||
|
|
||||||
|
if (!chartArea) {
|
||||||
|
return 'rgb(156, 163, 175)'; // fallback color
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create gradient that changes color based on data points
|
||||||
|
const gradient = ctx.createLinearGradient(chartArea.left, 0, chartArea.right, 0);
|
||||||
|
|
||||||
|
// Add color stops based on data points
|
||||||
|
profitPercentages.forEach((_, index) => {
|
||||||
|
const stop = index / (profitPercentages.length - 1);
|
||||||
|
gradient.addColorStop(stop, pointColors[index]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return gradient;
|
||||||
|
},
|
||||||
|
backgroundColor: pointColors,
|
||||||
|
pointBackgroundColor: pointColors,
|
||||||
|
pointBorderColor: pointColors,
|
||||||
|
pointRadius: 6,
|
||||||
|
pointHoverRadius: 8,
|
||||||
|
tension: 0.3,
|
||||||
|
fill: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}, [buckets]);
|
||||||
|
|
||||||
|
const options = useMemo(() => ({
|
||||||
|
responsive: true,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index' as const,
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
display: false, // Remove legend since we only have one metric
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context: TooltipItem<'line'>) => {
|
||||||
|
const dataIndex = context.dataIndex;
|
||||||
|
const bucket = buckets[dataIndex];
|
||||||
|
if (bucket) {
|
||||||
|
const profitPercent = context.parsed.y.toFixed(2);
|
||||||
|
const profitDollar = bucket.profit.toLocaleString('en-US', {
|
||||||
|
style: 'currency',
|
||||||
|
currency: 'USD',
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
return [`Profit: ${profitPercent}%`, `Amount: ${profitDollar}`];
|
||||||
|
}
|
||||||
|
return `Profit: ${context.parsed.y.toFixed(2)}%`;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
type: 'linear' as const,
|
||||||
|
display: true,
|
||||||
|
position: 'left' as const,
|
||||||
|
min: 0,
|
||||||
|
max: 50,
|
||||||
|
ticks: {
|
||||||
|
stepSize: 5,
|
||||||
|
callback: (value: number | string) => `${Number(value).toFixed(0)}%`,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Profit %',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
ticks: {
|
||||||
|
maxRotation: 90,
|
||||||
|
minRotation: 90,
|
||||||
|
maxTicksLimit: undefined, // Show all labels
|
||||||
|
autoSkip: false, // Don't skip any labels
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}), [buckets]);
|
||||||
|
|
||||||
|
if (isLoading && !chartData) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profit Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-64 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chartData) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Profit Trend</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="h-72">
|
||||||
|
<Line data={chartData} options={options} />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
166
inventory/src/components/discount-simulator/ResultsTable.tsx
Normal file
166
inventory/src/components/discount-simulator/ResultsTable.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { DiscountSimulationBucket } from "@/types/discount-simulator";
|
||||||
|
import { formatCurrency, formatNumber } from "@/utils/productUtils";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
// Utility function to interpolate between two colors
|
||||||
|
const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => {
|
||||||
|
return [
|
||||||
|
Math.round(color1[0] + (color2[0] - color1[0]) * ratio),
|
||||||
|
Math.round(color1[1] + (color2[1] - color1[1]) * ratio),
|
||||||
|
Math.round(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to calculate color based on profit percentage
|
||||||
|
const getProfitPercentageColor = (percentage: number): string => {
|
||||||
|
const percent = percentage * 100; // Convert to percentage
|
||||||
|
|
||||||
|
// Define color points (RGB values for red-400, yellow-400, green-400)
|
||||||
|
const red: [number, number, number] = [248, 113, 113]; // red-400
|
||||||
|
const yellow: [number, number, number] = [251, 191, 36]; // yellow-400
|
||||||
|
const green: [number, number, number] = [74, 222, 128]; // green-400
|
||||||
|
|
||||||
|
const min = 25;
|
||||||
|
const mid = 29;
|
||||||
|
const max = 35;
|
||||||
|
|
||||||
|
let rgb: [number, number, number];
|
||||||
|
|
||||||
|
if (percent <= min) {
|
||||||
|
rgb = red;
|
||||||
|
} else if (percent >= max) {
|
||||||
|
rgb = green;
|
||||||
|
} else if (percent <= mid) {
|
||||||
|
// Interpolate between red and yellow
|
||||||
|
const ratio = (percent - min) / (mid - min);
|
||||||
|
rgb = interpolateColor(red, yellow, ratio);
|
||||||
|
} else {
|
||||||
|
// Interpolate between yellow and green
|
||||||
|
const ratio = (percent - mid) / (max - mid);
|
||||||
|
rgb = interpolateColor(yellow, green, ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ResultsTableProps {
|
||||||
|
buckets: DiscountSimulationBucket[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rowLabels = [
|
||||||
|
{ key: "orderCount", label: "Orders", format: (value: number) => formatNumber(value) },
|
||||||
|
{ key: "weight", label: "Weight", format: (value: number) => `${(value * 100).toFixed(2)}%` },
|
||||||
|
{ key: "orderValue", label: "Order Value", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "productDiscountAmount", label: "Product Discount", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "promoProductDiscount", label: "Promo Product Discount", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "customerItemCost", label: "Customer Item Cost", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "shippingAfterAuto", label: "Ship Charge", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "shipPromoDiscount", label: "Ship Promo Discount", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "customerShipCost", label: "Customer Ship Cost", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "actualShippingCost", label: "Actual Ship Cost", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "totalRevenue", label: "Revenue", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "productCogs", label: "Product COGS", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "merchantFees", label: "Merchant Fees", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "pointsCost", label: "Points Cost", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "fixedCosts", label: "Fixed Cost", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "totalCosts", label: "Total Cost", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "profit", label: "Profit", format: (value: number) => formatCurrency(value) },
|
||||||
|
{ key: "profitPercent", label: "Profit %", format: (value: number) => `${(value * 100).toFixed(2)}%` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
|
||||||
|
if (bucket.max == null) {
|
||||||
|
return `${formatCurrency(bucket.min)}+`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatCurrency(bucket.max);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||||
|
if (isLoading && buckets.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 py-3">
|
||||||
|
<CardTitle className="text-base font-semibold">Profitability by Order Value</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 pt-0">
|
||||||
|
<Skeleton className="h-36 w-full" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="px-4 py-3">
|
||||||
|
<CardTitle className="text-base font-semibold">Profitability by Order Value</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="px-4 pb-4 pt-0A">
|
||||||
|
<div className="overflow-x-auto max-w-full">
|
||||||
|
<Table className="text-sm w-auto">
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead className="font-medium sticky left-0 bg-background z-10 w-[140px]">Metric</TableHead>
|
||||||
|
{buckets.map((bucket) => (
|
||||||
|
<TableHead key={bucket.key} className="text-center whitespace-nowrap w-[100px]">
|
||||||
|
{formatRangeUpperBound(bucket)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{buckets.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={buckets.length + 1} className="text-center text-muted-foreground">
|
||||||
|
No data available for the selected configuration.
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
rowLabels.map((row) => (
|
||||||
|
<TableRow key={row.key}>
|
||||||
|
<TableCell className="font-medium sticky left-0 bg-background z-10 w-[140px]">{row.label}</TableCell>
|
||||||
|
{buckets.map((bucket) => {
|
||||||
|
const value = bucket[row.key as keyof DiscountSimulationBucket] as number;
|
||||||
|
const formattedValue = row.format(value);
|
||||||
|
|
||||||
|
// Apply color gradient for profit percentage
|
||||||
|
if (row.key === 'profitPercent') {
|
||||||
|
const backgroundColor = getProfitPercentageColor(value);
|
||||||
|
return (
|
||||||
|
<TableCell key={`${row.key}-${bucket.key}`} className="text-center whitespace-nowrap w-[100px]">
|
||||||
|
<span
|
||||||
|
className="inline-block px-2 py-1 rounded text-white font-medium"
|
||||||
|
style={{ backgroundColor }}
|
||||||
|
>
|
||||||
|
{formattedValue}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableCell key={`${row.key}-${bucket.key}`} className="text-center whitespace-nowrap w-[100px]">
|
||||||
|
{formattedValue}
|
||||||
|
</TableCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
inventory/src/components/discount-simulator/SummaryCard.tsx
Normal file
159
inventory/src/components/discount-simulator/SummaryCard.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { formatCurrency, formatNumber } from "@/utils/productUtils";
|
||||||
|
import { DiscountSimulationResponse } from "@/types/discount-simulator";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
|
||||||
|
// Utility function to interpolate between two colors
|
||||||
|
const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => {
|
||||||
|
return [
|
||||||
|
Math.round(color1[0] + (color2[0] - color1[0]) * ratio),
|
||||||
|
Math.round(color1[1] + (color2[1] - color1[1]) * ratio),
|
||||||
|
Math.round(color1[2] + (color2[2] - color1[2]) * ratio)
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Utility function to calculate color based on profit percentage
|
||||||
|
const getProfitPercentageColor = (percentage: number): string => {
|
||||||
|
const percent = percentage * 100; // Convert to percentage
|
||||||
|
|
||||||
|
// Define color points (RGB values for red-400, yellow-400, green-400)
|
||||||
|
const red: [number, number, number] = [248, 113, 113]; // red-400
|
||||||
|
const yellow: [number, number, number] = [251, 191, 36]; // yellow-400
|
||||||
|
const green: [number, number, number] = [74, 222, 128]; // green-400
|
||||||
|
|
||||||
|
const min = 25;
|
||||||
|
const mid = 29;
|
||||||
|
const max = 35;
|
||||||
|
|
||||||
|
let rgb: [number, number, number];
|
||||||
|
|
||||||
|
if (percent <= min) {
|
||||||
|
rgb = red;
|
||||||
|
} else if (percent >= max) {
|
||||||
|
rgb = green;
|
||||||
|
} else if (percent <= mid) {
|
||||||
|
// Interpolate between red and yellow
|
||||||
|
const ratio = (percent - min) / (mid - min);
|
||||||
|
rgb = interpolateColor(red, yellow, ratio);
|
||||||
|
} else {
|
||||||
|
// Interpolate between yellow and green
|
||||||
|
const ratio = (percent - mid) / (max - mid);
|
||||||
|
rgb = interpolateColor(yellow, green, ratio);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface SummaryCardProps {
|
||||||
|
result?: DiscountSimulationResponse;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPercent = (value: number) => {
|
||||||
|
if (!Number.isFinite(value)) return 'N/A';
|
||||||
|
return `${(value * 100).toFixed(2)}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SummaryCard({ result, isLoading }: SummaryCardProps) {
|
||||||
|
if (isLoading && !result) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Simulation Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-16" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-5 gap-6">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="text-center space-y-1">
|
||||||
|
<Skeleton className="h-3 w-16 mx-auto" />
|
||||||
|
<Skeleton className="h-4 w-12 mx-auto" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const totals = result?.totals;
|
||||||
|
const weightedProfitAmount = totals ? formatCurrency(totals.weightedProfitAmount) : '—';
|
||||||
|
const weightedProfitPercent = totals ? formatPercent(totals.weightedProfitPercent) : '—';
|
||||||
|
|
||||||
|
// Get color for profit percentage
|
||||||
|
const profitPercentageColor = totals ? getProfitPercentageColor(totals.weightedProfitPercent) : '';
|
||||||
|
|
||||||
|
const weightedBadgeVariant = totals
|
||||||
|
? totals.weightedProfitAmount >= 0
|
||||||
|
? "default"
|
||||||
|
: "destructive"
|
||||||
|
: "secondary";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Simulation Summary</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{/* Left side - Main profit metrics */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-muted-foreground">Weighted Profit per Order</p>
|
||||||
|
<div className="text-3xl font-semibold">{weightedProfitAmount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{totals ? (
|
||||||
|
<span
|
||||||
|
className="inline-block px-3 py-1 rounded text-white font-medium text-lg"
|
||||||
|
style={{ backgroundColor: profitPercentageColor }}
|
||||||
|
>
|
||||||
|
{weightedProfitPercent}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Badge variant={weightedBadgeVariant} className="text-lg py-1 px-3">
|
||||||
|
{weightedProfitPercent}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Secondary metrics */}
|
||||||
|
{totals && (
|
||||||
|
<div className="grid grid-cols-5 gap-6 text-sm">
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground mb-1">Orders</p>
|
||||||
|
<p className="font-medium">{formatNumber(totals.orders)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground mb-1">Avg Discount</p>
|
||||||
|
<p className="font-medium">{formatPercent(totals.productDiscountRate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground mb-1">Points/$</p>
|
||||||
|
<p className="font-medium">{totals.pointsPerDollar.toFixed(4)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground mb-1">Redeemed</p>
|
||||||
|
<p className="font-medium">{formatPercent(totals.redemptionRate)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-muted-foreground mb-1">Point Value</p>
|
||||||
|
<p className="font-medium">{formatCurrency(totals.pointDollarValue, 4)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Truck,
|
Truck,
|
||||||
MessageCircle,
|
MessageCircle,
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
|
Percent,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { IconCrystalBall } from "@tabler/icons-react";
|
import { IconCrystalBall } from "@tabler/icons-react";
|
||||||
import {
|
import {
|
||||||
@@ -84,6 +85,12 @@ const inventoryItems = [
|
|||||||
url: "/analytics",
|
url: "/analytics",
|
||||||
permission: "access:analytics"
|
permission: "access:analytics"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Discount Simulator",
|
||||||
|
icon: Percent,
|
||||||
|
url: "/discount-simulator",
|
||||||
|
permission: "access:discount_simulator"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Forecasting",
|
title: "Forecasting",
|
||||||
icon: IconCrystalBall,
|
icon: IconCrystalBall,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function DateRangePicker({
|
|||||||
id="date"
|
id="date"
|
||||||
variant={"outline"}
|
variant={"outline"}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-8 w-[300px] justify-start text-left font-normal",
|
"h-8 w-full justify-start text-left font-normal",
|
||||||
!value && "text-muted-foreground"
|
!value && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
313
inventory/src/pages/DiscountSimulator.tsx
Normal file
313
inventory/src/pages/DiscountSimulator.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { DateRange } from "react-day-picker";
|
||||||
|
import { subMonths, startOfDay, endOfDay } from "date-fns";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
|
||||||
|
import { acotService } from "@/services/dashboard/acotService";
|
||||||
|
import { ConfigPanel } from "@/components/discount-simulator/ConfigPanel";
|
||||||
|
import { SummaryCard } from "@/components/discount-simulator/SummaryCard";
|
||||||
|
import { ResultsChart } from "@/components/discount-simulator/ResultsChart";
|
||||||
|
import { ResultsTable } from "@/components/discount-simulator/ResultsTable";
|
||||||
|
import {
|
||||||
|
DiscountPromoOption,
|
||||||
|
DiscountSimulationRequest,
|
||||||
|
DiscountSimulationResponse,
|
||||||
|
DiscountPromoType,
|
||||||
|
ShippingPromoType,
|
||||||
|
ShippingTierConfig,
|
||||||
|
} from "@/types/discount-simulator";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
|
||||||
|
const DEFAULT_POINT_VALUE = 0.005;
|
||||||
|
const DEFAULT_MERCHANT_FEE = 2.9;
|
||||||
|
const DEFAULT_FIXED_COST = 1.5;
|
||||||
|
|
||||||
|
const initialDateRange: DateRange = {
|
||||||
|
from: subMonths(new Date(), 6),
|
||||||
|
to: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureDateRange(range: DateRange): { from: Date; to: Date } {
|
||||||
|
const from = range.from ?? subMonths(new Date(), 6);
|
||||||
|
const to = range.to ?? new Date();
|
||||||
|
return { from, to };
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultProductPromo = {
|
||||||
|
type: 'none' as DiscountPromoType,
|
||||||
|
value: 0,
|
||||||
|
minSubtotal: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultShippingPromo = {
|
||||||
|
type: 'none' as ShippingPromoType,
|
||||||
|
value: 0,
|
||||||
|
minSubtotal: 0,
|
||||||
|
maxDiscount: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DiscountSimulator() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [dateRange, setDateRange] = useState<DateRange>(initialDateRange);
|
||||||
|
const [selectedPromoId, setSelectedPromoId] = useState<number | undefined>(undefined);
|
||||||
|
const [productPromo, setProductPromo] = useState(defaultProductPromo);
|
||||||
|
const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo);
|
||||||
|
const [shippingTiers, setShippingTiers] = useState<ShippingTierConfig[]>([]);
|
||||||
|
const [merchantFeePercent, setMerchantFeePercent] = useState(DEFAULT_MERCHANT_FEE);
|
||||||
|
const [fixedCostPerOrder, setFixedCostPerOrder] = useState(DEFAULT_FIXED_COST);
|
||||||
|
const [pointsConfig, setPointsConfig] = useState({
|
||||||
|
pointsPerDollar: 0,
|
||||||
|
redemptionRate: 0,
|
||||||
|
pointDollarValue: DEFAULT_POINT_VALUE,
|
||||||
|
});
|
||||||
|
const [pointsTouched, setPointsTouched] = useState(false);
|
||||||
|
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||||
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
|
const initialRunRef = useRef(false);
|
||||||
|
const skipAutoRunRef = useRef(false);
|
||||||
|
const latestPayloadKeyRef = useRef('');
|
||||||
|
const pendingCountRef = useRef(0);
|
||||||
|
|
||||||
|
const promosQuery = useQuery<DiscountPromoOption[]>({
|
||||||
|
queryKey: ['discount-promos'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await acotService.getDiscountPromos() as { promos?: Array<Record<string, unknown>> };
|
||||||
|
const rawList = Array.isArray(response?.promos)
|
||||||
|
? response.promos
|
||||||
|
: [];
|
||||||
|
const list: DiscountPromoOption[] = rawList.map((promo) => {
|
||||||
|
|
||||||
|
const idValue = promo.id ?? promo.promo_id ?? 0;
|
||||||
|
const codeValue = promo.code ?? promo.promo_code ?? '';
|
||||||
|
const descriptionValue =
|
||||||
|
(promo.description as string | undefined) ||
|
||||||
|
(promo.promo_description_online as string | undefined) ||
|
||||||
|
(promo.promo_description_private as string | undefined) ||
|
||||||
|
(typeof codeValue === 'string' ? codeValue : '');
|
||||||
|
|
||||||
|
const dateStartValue = (promo.dateStart as string | undefined) || (promo.date_start as string | undefined) || null;
|
||||||
|
const dateEndValue = (promo.dateEnd as string | undefined) || (promo.date_end as string | undefined) || null;
|
||||||
|
const usageValue = promo.usageCount ?? promo.usage_count ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(idValue) || 0,
|
||||||
|
code: typeof codeValue === 'string' ? codeValue : '',
|
||||||
|
description: descriptionValue ?? '',
|
||||||
|
dateStart: dateStartValue,
|
||||||
|
dateEnd: dateEndValue,
|
||||||
|
usageCount: Number(usageValue) || 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return list;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const createPayload = useCallback((): DiscountSimulationRequest => {
|
||||||
|
const { from, to } = ensureDateRange(dateRange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dateRange: {
|
||||||
|
start: startOfDay(from).toISOString(),
|
||||||
|
end: endOfDay(to).toISOString(),
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
shipCountry: 'US',
|
||||||
|
promoIds: selectedPromoId ? [selectedPromoId] : undefined,
|
||||||
|
},
|
||||||
|
productPromo,
|
||||||
|
shippingPromo,
|
||||||
|
shippingTiers,
|
||||||
|
merchantFeePercent,
|
||||||
|
fixedCostPerOrder,
|
||||||
|
pointsConfig: {
|
||||||
|
pointsPerDollar: pointsConfig.pointsPerDollar,
|
||||||
|
redemptionRate: pointsConfig.redemptionRate,
|
||||||
|
pointDollarValue: pointsConfig.pointDollarValue,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointsConfig]);
|
||||||
|
|
||||||
|
const simulationMutation = useMutation<
|
||||||
|
DiscountSimulationResponse,
|
||||||
|
unknown,
|
||||||
|
DiscountSimulationRequest
|
||||||
|
>({
|
||||||
|
mutationKey: ['discount-simulation'],
|
||||||
|
mutationFn: async (payload: DiscountSimulationRequest) => {
|
||||||
|
const response = await acotService.simulateDiscounts(payload);
|
||||||
|
return response as DiscountSimulationResponse;
|
||||||
|
},
|
||||||
|
onMutate: () => {
|
||||||
|
pendingCountRef.current += 1;
|
||||||
|
setIsSimulating(true);
|
||||||
|
},
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
const payloadKey = JSON.stringify(variables);
|
||||||
|
if (payloadKey !== latestPayloadKeyRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSimulationResult(data);
|
||||||
|
|
||||||
|
if (!pointsTouched) {
|
||||||
|
skipAutoRunRef.current = true;
|
||||||
|
setPointsConfig((prev) => {
|
||||||
|
if (
|
||||||
|
prev.pointsPerDollar === data.totals.pointsPerDollar &&
|
||||||
|
prev.redemptionRate === data.totals.redemptionRate &&
|
||||||
|
prev.pointDollarValue === data.totals.pointDollarValue
|
||||||
|
) {
|
||||||
|
skipAutoRunRef.current = false;
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
pointsPerDollar: data.totals.pointsPerDollar,
|
||||||
|
redemptionRate: data.totals.redemptionRate,
|
||||||
|
pointDollarValue: data.totals.pointDollarValue,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error('Simulation error', error);
|
||||||
|
toast({
|
||||||
|
title: 'Simulation failed',
|
||||||
|
description: error instanceof Error ? error.message : 'Unable to run discount simulation right now.',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
pendingCountRef.current = Math.max(0, pendingCountRef.current - 1);
|
||||||
|
if (pendingCountRef.current === 0) {
|
||||||
|
setIsSimulating(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { mutate } = simulationMutation;
|
||||||
|
|
||||||
|
const runSimulation = useCallback((overridePayload?: DiscountSimulationRequest) => {
|
||||||
|
const payload = overridePayload ?? createPayload();
|
||||||
|
latestPayloadKeyRef.current = JSON.stringify(payload);
|
||||||
|
mutate(payload);
|
||||||
|
}, [createPayload, mutate]);
|
||||||
|
|
||||||
|
const serializedConfig = useMemo(() => {
|
||||||
|
const { from, to } = ensureDateRange(dateRange);
|
||||||
|
return JSON.stringify({
|
||||||
|
dateRange: {
|
||||||
|
start: startOfDay(from).toISOString(),
|
||||||
|
end: endOfDay(to).toISOString(),
|
||||||
|
},
|
||||||
|
selectedPromoId: selectedPromoId ?? null,
|
||||||
|
productPromo,
|
||||||
|
shippingPromo,
|
||||||
|
shippingTiers,
|
||||||
|
merchantFeePercent,
|
||||||
|
fixedCostPerOrder,
|
||||||
|
pointsConfig,
|
||||||
|
});
|
||||||
|
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointsConfig]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!initialRunRef.current) {
|
||||||
|
initialRunRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skipAutoRunRef.current) {
|
||||||
|
skipAutoRunRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
runSimulation();
|
||||||
|
}, [serializedConfig, runSimulation]);
|
||||||
|
|
||||||
|
const recommendedPoints = simulationResult?.totals
|
||||||
|
? {
|
||||||
|
pointsPerDollar: simulationResult.totals.pointsPerDollar,
|
||||||
|
redemptionRate: simulationResult.totals.redemptionRate,
|
||||||
|
pointDollarValue: simulationResult.totals.pointDollarValue,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handlePointsChange = (update: Partial<typeof pointsConfig>) => {
|
||||||
|
setPointsTouched(true);
|
||||||
|
setPointsConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
...update,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleApplyRecommendedPoints = () => {
|
||||||
|
if (recommendedPoints) {
|
||||||
|
setPointsConfig(recommendedPoints);
|
||||||
|
setPointsTouched(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const promos = promosQuery.data ?? [];
|
||||||
|
|
||||||
|
const isLoading = isSimulating && !simulationResult;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div layout className="flex-1 p-8 pt-6">
|
||||||
|
<div className="flex flex-col gap-4 mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Discount Simulator</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 lg:grid-cols-[300px,1fr] xl:grid-cols-[300px,1fr]">
|
||||||
|
{/* Left Sidebar - Configuration */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<ConfigPanel
|
||||||
|
dateRange={dateRange}
|
||||||
|
onDateRangeChange={(range) => range && setDateRange(range)}
|
||||||
|
promos={promos}
|
||||||
|
selectedPromoId={selectedPromoId}
|
||||||
|
onPromoChange={setSelectedPromoId}
|
||||||
|
productPromo={productPromo}
|
||||||
|
onProductPromoChange={(update) => setProductPromo((prev) => ({ ...prev, ...update }))}
|
||||||
|
shippingPromo={shippingPromo}
|
||||||
|
onShippingPromoChange={(update) => setShippingPromo((prev) => ({ ...prev, ...update }))}
|
||||||
|
shippingTiers={shippingTiers}
|
||||||
|
onShippingTiersChange={setShippingTiers}
|
||||||
|
merchantFeePercent={merchantFeePercent}
|
||||||
|
onMerchantFeeChange={setMerchantFeePercent}
|
||||||
|
fixedCostPerOrder={fixedCostPerOrder}
|
||||||
|
onFixedCostChange={setFixedCostPerOrder}
|
||||||
|
pointsPerDollar={pointsConfig.pointsPerDollar}
|
||||||
|
redemptionRate={pointsConfig.redemptionRate}
|
||||||
|
pointDollarValue={pointsConfig.pointDollarValue}
|
||||||
|
onPointsChange={handlePointsChange}
|
||||||
|
onRunSimulation={() => runSimulation()}
|
||||||
|
isRunning={isSimulating}
|
||||||
|
recommendedPoints={recommendedPoints}
|
||||||
|
onApplyRecommendedPoints={handleApplyRecommendedPoints}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Side - Results */}
|
||||||
|
<div className="space-y-4 min-w-0 flex-1">
|
||||||
|
{/* Top Right - Summary (Full Width) */}
|
||||||
|
<div className="w-full">
|
||||||
|
<SummaryCard result={simulationResult} isLoading={isLoading} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle Right - Chart (Full Width) */}
|
||||||
|
<div className="w-full">
|
||||||
|
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom Right - Table */}
|
||||||
|
<div className="w-full">
|
||||||
|
<ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DiscountSimulator;
|
||||||
@@ -179,6 +179,36 @@ export const acotService = {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getDiscountPromos: async () => {
|
||||||
|
const cacheKey = 'discount_promos';
|
||||||
|
return deduplicatedRequest(
|
||||||
|
cacheKey,
|
||||||
|
() =>
|
||||||
|
retryRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await acotApi.get('/api/acot/discounts/promos', {
|
||||||
|
timeout: 60000,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
0
|
||||||
|
),
|
||||||
|
60 * 1000
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
simulateDiscounts: async (payload) => {
|
||||||
|
return retryRequest(
|
||||||
|
async () => {
|
||||||
|
const response = await acotApi.post('/api/acot/discounts/simulate', payload, {
|
||||||
|
timeout: 120000,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
0
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// Utility functions
|
// Utility functions
|
||||||
clearCache,
|
clearCache,
|
||||||
};
|
};
|
||||||
|
|||||||
6
inventory/src/types/dashboard-shims.d.ts
vendored
6
inventory/src/types/dashboard-shims.d.ts
vendored
@@ -1,6 +1,12 @@
|
|||||||
declare module "@/services/dashboard/acotService" {
|
declare module "@/services/dashboard/acotService" {
|
||||||
const acotService: {
|
const acotService: {
|
||||||
|
getStats: (params: unknown) => Promise<unknown>;
|
||||||
|
getStatsDetails: (params: unknown) => Promise<unknown>;
|
||||||
|
getProducts: (params: unknown) => Promise<unknown>;
|
||||||
getFinancials: (params: unknown) => Promise<unknown>;
|
getFinancials: (params: unknown) => Promise<unknown>;
|
||||||
|
getProjection: (params: unknown) => Promise<unknown>;
|
||||||
|
getDiscountPromos: () => Promise<unknown>;
|
||||||
|
simulateDiscounts: (payload: unknown) => Promise<unknown>;
|
||||||
[key: string]: (...args: never[]) => Promise<unknown> | unknown;
|
[key: string]: (...args: never[]) => Promise<unknown> | unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
94
inventory/src/types/discount-simulator.ts
Normal file
94
inventory/src/types/discount-simulator.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
export interface DiscountPromoOption {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
dateStart: string | null;
|
||||||
|
dateEnd: string | null;
|
||||||
|
usageCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DiscountPromoType = 'none' | 'percentage_subtotal' | 'percentage_regular' | 'fixed_amount';
|
||||||
|
export type ShippingPromoType = 'none' | 'percentage' | 'fixed';
|
||||||
|
export type ShippingTierMode = 'percentage' | 'flat';
|
||||||
|
|
||||||
|
export interface ShippingTierConfig {
|
||||||
|
threshold: number;
|
||||||
|
mode: ShippingTierMode;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountSimulationBucket {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
min: number;
|
||||||
|
max: number | null;
|
||||||
|
orderCount: number;
|
||||||
|
weight: number;
|
||||||
|
orderValue: number;
|
||||||
|
productDiscountAmount: number;
|
||||||
|
promoProductDiscount: number;
|
||||||
|
customerItemCost: number;
|
||||||
|
shippingChargeBase: number;
|
||||||
|
shippingAfterAuto: number;
|
||||||
|
shipPromoDiscount: number;
|
||||||
|
customerShipCost: number;
|
||||||
|
actualShippingCost: number;
|
||||||
|
totalRevenue: number;
|
||||||
|
productCogs: number;
|
||||||
|
merchantFees: number;
|
||||||
|
pointsCost: number;
|
||||||
|
fixedCosts: number;
|
||||||
|
totalCosts: number;
|
||||||
|
profit: number;
|
||||||
|
profitPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountSimulationTotals {
|
||||||
|
orders: number;
|
||||||
|
subtotal: number;
|
||||||
|
productDiscountRate: number;
|
||||||
|
pointsPerDollar: number;
|
||||||
|
redemptionRate: number;
|
||||||
|
pointDollarValue: number;
|
||||||
|
weightedProfitAmount: number;
|
||||||
|
weightedProfitPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountSimulationResponse {
|
||||||
|
dateRange: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
totals: DiscountSimulationTotals;
|
||||||
|
buckets: DiscountSimulationBucket[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiscountSimulationRequest {
|
||||||
|
dateRange: {
|
||||||
|
start: string;
|
||||||
|
end: string;
|
||||||
|
};
|
||||||
|
filters: {
|
||||||
|
shipCountry?: string;
|
||||||
|
promoIds?: number[];
|
||||||
|
};
|
||||||
|
productPromo: {
|
||||||
|
type: DiscountPromoType;
|
||||||
|
value: number;
|
||||||
|
minSubtotal: number;
|
||||||
|
};
|
||||||
|
shippingPromo: {
|
||||||
|
type: ShippingPromoType;
|
||||||
|
value: number;
|
||||||
|
minSubtotal: number;
|
||||||
|
maxDiscount: number;
|
||||||
|
};
|
||||||
|
shippingTiers: ShippingTierConfig[];
|
||||||
|
merchantFeePercent: number;
|
||||||
|
fixedCostPerOrder: number;
|
||||||
|
pointsConfig: {
|
||||||
|
pointsPerDollar: number;
|
||||||
|
redemptionRate: number;
|
||||||
|
pointDollarValue: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user