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
|
||||
app.use('/api/acot/test', require('./routes/test'));
|
||||
app.use('/api/acot/events', require('./routes/events'));
|
||||
app.use('/api/acot/discounts', require('./routes/discounts'));
|
||||
|
||||
// Error handling middleware
|
||||
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 Analytics = lazy(() => import('./pages/Analytics').then(module => ({ default: module.Analytics })));
|
||||
const Forecasting = lazy(() => import('./pages/Forecasting'));
|
||||
const DiscountSimulator = lazy(() => import('./pages/DiscountSimulator'));
|
||||
const Vendors = lazy(() => import('./pages/Vendors'));
|
||||
const Categories = lazy(() => import('./pages/Categories'));
|
||||
const Brands = lazy(() => import('./pages/Brands'));
|
||||
@@ -151,6 +152,13 @@ function App() {
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/discount-simulator" element={
|
||||
<Protected page="discount_simulator">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
<DiscountSimulator />
|
||||
</Suspense>
|
||||
</Protected>
|
||||
} />
|
||||
<Route path="/forecasting" element={
|
||||
<Protected page="forecasting">
|
||||
<Suspense fallback={<PageLoading />}>
|
||||
@@ -202,4 +210,3 @@ function App() {
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const PAGES = [
|
||||
{ path: "/vendors", permission: "access:vendors" },
|
||||
{ path: "/purchase-orders", permission: "access:purchase_orders" },
|
||||
{ path: "/analytics", permission: "access:analytics" },
|
||||
{ path: "/discount-simulator", permission: "access:discount_simulator" },
|
||||
{ path: "/forecasting", permission: "access:forecasting" },
|
||||
{ path: "/import", permission: "access:import" },
|
||||
{ path: "/settings", permission: "access:settings" },
|
||||
|
||||
@@ -133,6 +133,7 @@ Admin users automatically have all permissions.
|
||||
| `access:vendors` | Access to Vendors page |
|
||||
| `access:purchase_orders` | Access to Purchase Orders page |
|
||||
| `access:analytics` | Access to Analytics page |
|
||||
| `access:discount_simulator` | Access to Discount Simulator page |
|
||||
| `access:forecasting` | Access to Forecasting page |
|
||||
| `access:import` | Access to Import 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,
|
||||
MessageCircle,
|
||||
LayoutDashboard,
|
||||
Percent,
|
||||
} from "lucide-react";
|
||||
import { IconCrystalBall } from "@tabler/icons-react";
|
||||
import {
|
||||
@@ -84,6 +85,12 @@ const inventoryItems = [
|
||||
url: "/analytics",
|
||||
permission: "access:analytics"
|
||||
},
|
||||
{
|
||||
title: "Discount Simulator",
|
||||
icon: Percent,
|
||||
url: "/discount-simulator",
|
||||
permission: "access:discount_simulator"
|
||||
},
|
||||
{
|
||||
title: "Forecasting",
|
||||
icon: IconCrystalBall,
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DateRangePicker({
|
||||
id="date"
|
||||
variant={"outline"}
|
||||
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"
|
||||
)}
|
||||
>
|
||||
|
||||
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
|
||||
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" {
|
||||
const acotService: {
|
||||
getStats: (params: unknown) => Promise<unknown>;
|
||||
getStatsDetails: (params: unknown) => Promise<unknown>;
|
||||
getProducts: (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;
|
||||
};
|
||||
|
||||
|
||||
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