Add discount simulator

This commit is contained in:
2025-09-24 21:53:46 -04:00
parent 138251cf86
commit 6e30ba60ff
15 changed files with 1945 additions and 4 deletions

View 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;

View File

@@ -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) => {
@@ -95,4 +96,4 @@ const gracefulShutdown = async () => {
process.on('SIGTERM', gracefulShutdown); process.on('SIGTERM', gracefulShutdown);
process.on('SIGINT', gracefulShutdown); process.on('SIGINT', gracefulShutdown);
module.exports = app; module.exports = app;

View File

@@ -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;

View File

@@ -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" },

View File

@@ -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 |
@@ -201,4 +202,4 @@ function handleAction() {
- **Settings Access**: These permissions control access to different sections within the Settings page - **Settings Access**: These permissions control access to different sections within the Settings page
- **Admin Features**: Special permissions for administrative functions - **Admin Features**: Special permissions for administrative functions
- **CRUD Operations**: The application currently focuses on viewing and managing data rather than creating/editing/deleting individual records - **CRUD Operations**: The application currently focuses on viewing and managing data rather than creating/editing/deleting individual records
- **User Management**: User CRUD operations are handled through the settings interface rather than dedicated user management pages - **User Management**: User CRUD operations are handled through the settings interface rather than dedicated user management pages

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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,

View File

@@ -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"
)} )}
> >

View 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;

View File

@@ -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,
}; };

View File

@@ -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;
}; };

View 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;
};
}