From 6e30ba60ffe18224c136464d02e902744dd223c8 Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 24 Sep 2025 21:53:46 -0400 Subject: [PATCH] Add discount simulator --- .../dashboard/acot-server/routes/discounts.js | 475 ++++++++++++++++++ .../dashboard/acot-server/server.js | 3 +- inventory/src/App.tsx | 9 +- .../components/auth/FirstAccessiblePage.tsx | 1 + inventory/src/components/auth/PERMISSIONS.md | 3 +- .../discount-simulator/ConfigPanel.tsx | 474 +++++++++++++++++ .../discount-simulator/ResultsChart.tsx | 207 ++++++++ .../discount-simulator/ResultsTable.tsx | 166 ++++++ .../discount-simulator/SummaryCard.tsx | 159 ++++++ .../src/components/layout/AppSidebar.tsx | 7 + .../src/components/ui/date-range-picker.tsx | 2 +- inventory/src/pages/DiscountSimulator.tsx | 313 ++++++++++++ .../src/services/dashboard/acotService.js | 30 ++ inventory/src/types/dashboard-shims.d.ts | 6 + inventory/src/types/discount-simulator.ts | 94 ++++ 15 files changed, 1945 insertions(+), 4 deletions(-) create mode 100644 inventory-server/dashboard/acot-server/routes/discounts.js create mode 100644 inventory/src/components/discount-simulator/ConfigPanel.tsx create mode 100644 inventory/src/components/discount-simulator/ResultsChart.tsx create mode 100644 inventory/src/components/discount-simulator/ResultsTable.tsx create mode 100644 inventory/src/components/discount-simulator/SummaryCard.tsx create mode 100644 inventory/src/pages/DiscountSimulator.tsx create mode 100644 inventory/src/types/discount-simulator.ts diff --git a/inventory-server/dashboard/acot-server/routes/discounts.js b/inventory-server/dashboard/acot-server/routes/discounts.js new file mode 100644 index 0000000..51103da --- /dev/null +++ b/inventory-server/dashboard/acot-server/routes/discounts.js @@ -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; diff --git a/inventory-server/dashboard/acot-server/server.js b/inventory-server/dashboard/acot-server/server.js index 0f5ff92..4a70601 100644 --- a/inventory-server/dashboard/acot-server/server.js +++ b/inventory-server/dashboard/acot-server/server.js @@ -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) => { @@ -95,4 +96,4 @@ const gracefulShutdown = async () => { process.on('SIGTERM', gracefulShutdown); process.on('SIGINT', gracefulShutdown); -module.exports = app; \ No newline at end of file +module.exports = app; diff --git a/inventory/src/App.tsx b/inventory/src/App.tsx index 32d2b06..42ffec4 100644 --- a/inventory/src/App.tsx +++ b/inventory/src/App.tsx @@ -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() { } /> + + }> + + + + } /> }> @@ -202,4 +210,3 @@ function App() { } export default App; - diff --git a/inventory/src/components/auth/FirstAccessiblePage.tsx b/inventory/src/components/auth/FirstAccessiblePage.tsx index 15fcf8d..40c891d 100644 --- a/inventory/src/components/auth/FirstAccessiblePage.tsx +++ b/inventory/src/components/auth/FirstAccessiblePage.tsx @@ -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" }, diff --git a/inventory/src/components/auth/PERMISSIONS.md b/inventory/src/components/auth/PERMISSIONS.md index ec22596..d99b4fa 100644 --- a/inventory/src/components/auth/PERMISSIONS.md +++ b/inventory/src/components/auth/PERMISSIONS.md @@ -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 | @@ -201,4 +202,4 @@ function handleAction() { - **Settings Access**: These permissions control access to different sections within the Settings page - **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 -- **User Management**: User CRUD operations are handled through the settings interface rather than dedicated user management pages \ No newline at end of file +- **User Management**: User CRUD operations are handled through the settings interface rather than dedicated user management pages diff --git a/inventory/src/components/discount-simulator/ConfigPanel.tsx b/inventory/src/components/discount-simulator/ConfigPanel.tsx new file mode 100644 index 0000000..6c892b1 --- /dev/null +++ b/inventory/src/components/discount-simulator/ConfigPanel.tsx @@ -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) => void; + shippingPromo: { + type: ShippingPromoType; + value: number; + minSubtotal: number; + maxDiscount: number; + }; + onShippingPromoChange: (update: Partial) => 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) => { + 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 ( + + +
+
+ Filters +
+
+ + onDateRangeChange(range)} + className="h-9" + /> +
+
+ + +
+
+
+ +
+ Product promo +
+
+ + +
+ {showProductAdjustments && ( +
+
+ + onProductPromoChange({ value: parseNumber(event.target.value, 0) })} + /> +
+
+ + onProductPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })} + /> +
+
+ )} +
+
+ +
+ Shipping promo +
+
+ + +
+ {showShippingAdjustments && ( + <> +
+
+ + onShippingPromoChange({ value: parseNumber(event.target.value, 0) })} + /> +
+
+ + onShippingPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })} + /> +
+
+
+ + onShippingPromoChange({ maxDiscount: parseNumber(event.target.value, 0) })} + /> +
+ + )} +
+
+ +
+
+ Shipping tiers + +
+ {shippingTiers.length === 0 ? ( +

Add tiers to model automatic shipping discounts.

+ ) : ( + +
+ {shippingTiers.map((tier, index) => ( +
+ + Tier {index + 1} + +
+ + + handleTierUpdate(index, { + threshold: parseNumber(event.target.value, 0), + }) + } + /> +
+
+ + +
+
+ + + handleTierUpdate(index, { value: parseNumber(event.target.value, 0) }) + } + /> +
+
+ +
+
+ ))} +
+
+ )} +
+ + +
+ Order costs +
+
+ + onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent))} + /> +
+
+ + onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder))} + /> +
+
+
+ +
+ Rewards points +
+
+
+ + + onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) }) + } + /> +
+
+ + + onPointsChange({ redemptionRate: parseNumber(event.target.value, redemptionRate * 100) / 100 }) + } + /> +
+
+
+ + + onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) }) + } + /> +
+ {recommendedPoints && ( +
+ {recommendedDiffers && onApplyRecommendedPoints && ( + + )} + + Recommended: {recommendedPoints.pointsPerDollar.toFixed(4)} pts/$ · {(recommendedPoints.redemptionRate * 100).toFixed(2)}% redeemed · ${recommendedPoints.pointDollarValue.toFixed(4)} per point + +
+ )} +
+
+
+ + +
+
+ ); +} diff --git a/inventory/src/components/discount-simulator/ResultsChart.tsx b/inventory/src/components/discount-simulator/ResultsChart.tsx new file mode 100644 index 0000000..a92e081 --- /dev/null +++ b/inventory/src/components/discount-simulator/ResultsChart.tsx @@ -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 ( + + + Profit Trend + + + + + + ); + } + + if (!chartData) { + return null; + } + + return ( + + + Profit Trend + + +
+ +
+
+
+ ); +} + diff --git a/inventory/src/components/discount-simulator/ResultsTable.tsx b/inventory/src/components/discount-simulator/ResultsTable.tsx new file mode 100644 index 0000000..e631abe --- /dev/null +++ b/inventory/src/components/discount-simulator/ResultsTable.tsx @@ -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 ( + + + Profitability by Order Value + + + + + + ); + } + + return ( + + + Profitability by Order Value + + +
+ + + + Metric + {buckets.map((bucket) => ( + + {formatRangeUpperBound(bucket)} + + ))} + + + + {buckets.length === 0 ? ( + + + No data available for the selected configuration. + + + ) : ( + rowLabels.map((row) => ( + + {row.label} + {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 ( + + + {formattedValue} + + + ); + } + + return ( + + {formattedValue} + + ); + })} + + )) + )} + +
+
+
+
+ ); +} diff --git a/inventory/src/components/discount-simulator/SummaryCard.tsx b/inventory/src/components/discount-simulator/SummaryCard.tsx new file mode 100644 index 0000000..8a5084a --- /dev/null +++ b/inventory/src/components/discount-simulator/SummaryCard.tsx @@ -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 ( + + + Simulation Summary + + +
+
+
+ + +
+ +
+
+ {Array.from({ length: 5 }).map((_, i) => ( +
+ + +
+ ))} +
+
+
+
+ ); + } + + 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 ( + + + Simulation Summary + + +
+ {/* Left side - Main profit metrics */} +
+
+

Weighted Profit per Order

+
{weightedProfitAmount}
+
+
+ {totals ? ( + + {weightedProfitPercent} + + ) : ( + + {weightedProfitPercent} + + )} +
+
+ + {/* Right side - Secondary metrics */} + {totals && ( +
+
+

Orders

+

{formatNumber(totals.orders)}

+
+
+

Avg Discount

+

{formatPercent(totals.productDiscountRate)}

+
+
+

Points/$

+

{totals.pointsPerDollar.toFixed(4)}

+
+
+

Redeemed

+

{formatPercent(totals.redemptionRate)}

+
+
+

Point Value

+

{formatCurrency(totals.pointDollarValue, 4)}

+
+
+ )} +
+
+
+ ); +} diff --git a/inventory/src/components/layout/AppSidebar.tsx b/inventory/src/components/layout/AppSidebar.tsx index 283ca3c..b279b04 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -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, diff --git a/inventory/src/components/ui/date-range-picker.tsx b/inventory/src/components/ui/date-range-picker.tsx index 7c3a112..a03c0c0 100644 --- a/inventory/src/components/ui/date-range-picker.tsx +++ b/inventory/src/components/ui/date-range-picker.tsx @@ -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" )} > diff --git a/inventory/src/pages/DiscountSimulator.tsx b/inventory/src/pages/DiscountSimulator.tsx new file mode 100644 index 0000000..795ccd9 --- /dev/null +++ b/inventory/src/pages/DiscountSimulator.tsx @@ -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(initialDateRange); + const [selectedPromoId, setSelectedPromoId] = useState(undefined); + const [productPromo, setProductPromo] = useState(defaultProductPromo); + const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo); + const [shippingTiers, setShippingTiers] = useState([]); + 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(undefined); + const [isSimulating, setIsSimulating] = useState(false); + const initialRunRef = useRef(false); + const skipAutoRunRef = useRef(false); + const latestPayloadKeyRef = useRef(''); + const pendingCountRef = useRef(0); + + const promosQuery = useQuery({ + queryKey: ['discount-promos'], + queryFn: async () => { + const response = await acotService.getDiscountPromos() as { promos?: Array> }; + 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) => { + setPointsTouched(true); + setPointsConfig((prev) => ({ + ...prev, + ...update, + })); + }; + + const handleApplyRecommendedPoints = () => { + if (recommendedPoints) { + setPointsConfig(recommendedPoints); + setPointsTouched(true); + } + }; + + const promos = promosQuery.data ?? []; + + const isLoading = isSimulating && !simulationResult; + + return ( + +
+

Discount Simulator

+
+ +
+ {/* Left Sidebar - Configuration */} +
+ 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} + /> +
+ + {/* Right Side - Results */} +
+ {/* Top Right - Summary (Full Width) */} +
+ +
+ + {/* Middle Right - Chart (Full Width) */} +
+ +
+ + {/* Bottom Right - Table */} +
+ +
+
+
+
+ ); +} + +export default DiscountSimulator; diff --git a/inventory/src/services/dashboard/acotService.js b/inventory/src/services/dashboard/acotService.js index 7565492..6f04f74 100644 --- a/inventory/src/services/dashboard/acotService.js +++ b/inventory/src/services/dashboard/acotService.js @@ -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, }; diff --git a/inventory/src/types/dashboard-shims.d.ts b/inventory/src/types/dashboard-shims.d.ts index a15c245..99300ac 100644 --- a/inventory/src/types/dashboard-shims.d.ts +++ b/inventory/src/types/dashboard-shims.d.ts @@ -1,6 +1,12 @@ declare module "@/services/dashboard/acotService" { const acotService: { + getStats: (params: unknown) => Promise; + getStatsDetails: (params: unknown) => Promise; + getProducts: (params: unknown) => Promise; getFinancials: (params: unknown) => Promise; + getProjection: (params: unknown) => Promise; + getDiscountPromos: () => Promise; + simulateDiscounts: (payload: unknown) => Promise; [key: string]: (...args: never[]) => Promise | unknown; }; diff --git a/inventory/src/types/discount-simulator.ts b/inventory/src/types/discount-simulator.ts new file mode 100644 index 0000000..e133c12 --- /dev/null +++ b/inventory/src/types/discount-simulator.ts @@ -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; + }; +}