From a161f4533d8dd4b654e7b85951a31c751833f428 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 25 Sep 2025 11:44:15 -0400 Subject: [PATCH] Regroup sidebar, discount sim layout updates and fixes --- .../dashboard/acot-server/routes/discounts.js | 26 +- .../discount-simulator/ConfigPanel.tsx | 395 +++++++++++++----- .../discount-simulator/ResultsChart.tsx | 33 +- .../discount-simulator/ResultsTable.tsx | 97 +++-- .../discount-simulator/SummaryCard.tsx | 105 ++--- .../src/components/layout/AppSidebar.tsx | 21 +- inventory/src/pages/DiscountSimulator.tsx | 203 ++++++++- .../src/services/dashboard/acotService.js | 9 +- inventory/src/types/dashboard-shims.d.ts | 2 +- inventory/src/types/discount-simulator.ts | 2 + 10 files changed, 634 insertions(+), 259 deletions(-) diff --git a/inventory-server/dashboard/acot-server/routes/discounts.js b/inventory-server/dashboard/acot-server/routes/discounts.js index 51103da..634b0b0 100644 --- a/inventory-server/dashboard/acot-server/routes/discounts.js +++ b/inventory-server/dashboard/acot-server/routes/discounts.js @@ -87,6 +87,19 @@ router.get('/promos', async (req, res) => { connection = conn; const releaseConnection = release; + const { startDate, endDate } = req.query || {}; + const now = DateTime.now().endOf('day'); + const defaultStart = now.minus({ years: 3 }).startOf('day'); + + const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart; + const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now; + + const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd; + const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart; + + const rangeStartSql = formatDateForSql(rangeStart); + const rangeEndSql = formatDateForSql(rangeEnd); + const sql = ` SELECT p.promo_id AS id, @@ -105,18 +118,25 @@ router.get('/promos', async (req, res) => { 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 + WHERE p.date_start IS NOT NULL + AND p.date_end IS NOT NULL + AND NOT (p.date_end < ? OR p.date_start > ?) + AND p.store = 1 + AND p.date_start >= '2010-01-01' + ORDER BY p.promo_id DESC LIMIT 200 `; - const [rows] = await connection.execute(sql); + const [rows] = await connection.execute(sql, [rangeStartSql, rangeEndSql]); releaseConnection(); const promos = rows.map(row => ({ id: Number(row.id), code: row.code, description: row.description_online || row.description_private || '', + privateDescription: row.description_private || '', + promo_description_online: row.description_online || '', + promo_description_private: row.description_private || '', dateStart: row.date_start, dateEnd: row.date_end, usageCount: Number(row.usage_count || 0) diff --git a/inventory/src/components/discount-simulator/ConfigPanel.tsx b/inventory/src/components/discount-simulator/ConfigPanel.tsx index 6c892b1..69249fe 100644 --- a/inventory/src/components/discount-simulator/ConfigPanel.tsx +++ b/inventory/src/components/discount-simulator/ConfigPanel.tsx @@ -1,4 +1,5 @@ -import { useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; +import { endOfDay, startOfDay } from "date-fns"; import { DateRange } from "react-day-picker"; import { DateRangePicker } from "@/components/ui/date-range-picker"; import { Card, CardContent } from "@/components/ui/card"; @@ -7,13 +8,18 @@ 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 { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse } from "@/types/discount-simulator"; import { Separator } from "@/components/ui/separator"; +import { Badge } from "@/components/ui/badge"; +import { formatCurrency, formatNumber } from "@/utils/productUtils"; +import { PlusIcon, X } from "lucide-react"; +import { Skeleton } from "@/components/ui/skeleton"; interface ConfigPanelProps { dateRange: DateRange; onDateRangeChange: (range: DateRange | undefined) => void; promos: DiscountPromoOption[]; + promoLoading?: boolean; selectedPromoId?: number; onPromoChange: (promoId: number | undefined) => void; productPromo: { @@ -43,6 +49,8 @@ interface ConfigPanelProps { redemptionRate?: number; pointDollarValue?: number; }) => void; + onConfigInputChange: () => void; + onResetConfig: () => void; onRunSimulation: () => void; isRunning: boolean; recommendedPoints?: { @@ -51,6 +59,7 @@ interface ConfigPanelProps { pointDollarValue: number; }; onApplyRecommendedPoints?: () => void; + result?: DiscountSimulationResponse; } function parseNumber(value: string, fallback = 0) { @@ -58,6 +67,36 @@ function parseNumber(value: string, fallback = 0) { return Number.isFinite(parsed) ? parsed : fallback; } +const formatPercent = (value: number) => { + if (!Number.isFinite(value)) return 'N/A'; + return `${(value * 100).toFixed(2)}%`; +}; + +const generateTierId = () => `tier-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + +const parseDateToTimestamp = (value?: string | null): number | undefined => { + if (!value) { + return undefined; + } + const timestamp = new Date(value).getTime(); + return Number.isFinite(timestamp) ? timestamp : undefined; +}; + +const promoOverlapsRange = ( + promo: DiscountPromoOption, + rangeStart?: number, + rangeEnd?: number +): boolean => { + if (rangeStart == null || rangeEnd == null) { + return true; + } + + const promoStart = parseDateToTimestamp(promo.dateStart) ?? Number.NEGATIVE_INFINITY; + const promoEnd = parseDateToTimestamp(promo.dateEnd) ?? Number.POSITIVE_INFINITY; + + return promoStart <= rangeEnd && promoEnd >= rangeStart; +}; + export function ConfigPanel({ dateRange, onDateRangeChange, @@ -78,24 +117,69 @@ export function ConfigPanel({ redemptionRate, pointDollarValue, onPointsChange, + onConfigInputChange, + onResetConfig, + promoLoading = false, onRunSimulation, isRunning, recommendedPoints, - onApplyRecommendedPoints + onApplyRecommendedPoints, + result }: ConfigPanelProps) { const promoOptions = useMemo(() => { - return promos.map((promo) => ({ - value: promo.id.toString(), - label: promo.description || promo.code, - description: promo.description, - })); - }, [promos]); + if (!Array.isArray(promos) || promos.length === 0) { + return []; + } + + const rangeStartDate = dateRange?.from ? startOfDay(dateRange.from) : undefined; + const rangeEndSource = dateRange?.to ?? dateRange?.from; + const rangeEndDate = rangeEndSource ? endOfDay(rangeEndSource) : undefined; + const rangeStartTimestamp = rangeStartDate?.getTime(); + const rangeEndTimestamp = rangeEndDate?.getTime(); + + const filteredPromos = promos.filter((promo) => + promoOverlapsRange(promo, rangeStartTimestamp, rangeEndTimestamp) + ); + + return filteredPromos + .sort((a, b) => Number(b.id) - Number(a.id)) + .map((promo) => { + const privateDescription = (promo.privateDescription ?? '').trim(); + return { + value: promo.id.toString(), + label: promo.code || `Promo ${promo.id}`, + description: privateDescription, + }; + }); + }, [promos, dateRange]); + + useEffect(() => { + if (shippingTiers.length === 0) { + return; + } + + const tiersMissingIds = shippingTiers.some((tier) => !tier.id); + if (!tiersMissingIds) { + return; + } + + const normalizedTiers = shippingTiers.map((tier) => + tier.id ? tier : { ...tier, id: generateTierId() } + ); + onShippingTiersChange(normalizedTiers); + }, [shippingTiers, onShippingTiersChange]); const handleTierUpdate = (index: number, update: Partial) => { const tiers = [...shippingTiers]; + const current = tiers[index]; + if (!current) { + return; + } + const tierId = current.id ?? generateTierId(); tiers[index] = { - ...tiers[index], + ...current, ...update, + id: tierId, }; const sorted = tiers .filter((tier) => tier != null) @@ -109,11 +193,13 @@ export function ConfigPanel({ }; const handleTierRemove = (index: number) => { + onConfigInputChange(); const tiers = shippingTiers.filter((_, i) => i !== index); onShippingTiersChange(tiers); }; const handleTierAdd = () => { + onConfigInputChange(); const lastThreshold = shippingTiers[shippingTiers.length - 1]?.threshold ?? 0; const tiers = [ ...shippingTiers, @@ -121,6 +207,7 @@ export function ConfigPanel({ threshold: lastThreshold, mode: "percentage" as const, value: 0, + id: generateTierId(), }, ]; onShippingTiersChange(tiers); @@ -144,39 +231,48 @@ export function ConfigPanel({ const showProductAdjustments = productPromo.type !== "none"; const showShippingAdjustments = shippingPromo.type !== "none"; + const handleFieldBlur = useCallback(() => { + setTimeout(onRunSimulation, 0); + }, [onRunSimulation]); + return (
- Filters + Calculated Metrics Filters
onDateRangeChange(range)} - className="h-9" />
- { + if (value === '__all__') { + onPromoChange(undefined); return; } const parsed = Number(value); onPromoChange(Number.isNaN(parsed) ? undefined : parsed); - }} - > - - - - - All promos + }} + > + + {promoLoading ? ( +
+ +
+ ) : ( + + )} +
+ + All promos {promoOptions.map((promo) => (
@@ -191,6 +287,27 @@ export function ConfigPanel({
+ + {/* Calculated Metrics */} + {result?.totals && ( +
+ + {formatNumber(result.totals.orders)} orders + + + {formatPercent(result.totals.productDiscountRate)} avg discount + + + {result.totals.pointsPerDollar.toFixed(4)} pts/$ + + + {formatPercent(result.totals.redemptionRate)} redeemed + + + {formatCurrency(result.totals.pointDollarValue, 4)} pt value + +
+ )}
@@ -223,8 +340,12 @@ export function ConfigPanel({ className={compactNumberClass} type="number" step="1" - value={Math.round(productPromo.value ?? 0)} - onChange={(event) => onProductPromoChange({ value: parseNumber(event.target.value, 0) })} + value={productPromo.value ?? 0} + onChange={(event) => { + onConfigInputChange(); + onProductPromoChange({ value: parseNumber(event.target.value, 0) }); + }} + onBlur={handleFieldBlur} />
@@ -233,8 +354,12 @@ export function ConfigPanel({ className={compactNumberClass} type="number" step="1" - value={Math.round(productPromo.minSubtotal ?? 0)} - onChange={(event) => onProductPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })} + value={productPromo.minSubtotal ?? 0} + onChange={(event) => { + onConfigInputChange(); + onProductPromoChange({ minSubtotal: parseNumber(event.target.value, 0) }); + }} + onBlur={handleFieldBlur} />
@@ -272,8 +397,12 @@ export function ConfigPanel({ className={compactNumberClass} type="number" step="1" - value={Math.round(shippingPromo.value ?? 0)} - onChange={(event) => onShippingPromoChange({ value: parseNumber(event.target.value, 0) })} + value={shippingPromo.value ?? 0} + onChange={(event) => { + onConfigInputChange(); + onShippingPromoChange({ value: parseNumber(event.target.value, 0) }); + }} + onBlur={handleFieldBlur} />
@@ -282,8 +411,12 @@ export function ConfigPanel({ className={compactNumberClass} type="number" step="1" - value={Math.round(shippingPromo.minSubtotal ?? 0)} - onChange={(event) => onShippingPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })} + value={shippingPromo.minSubtotal ?? 0} + onChange={(event) => { + onConfigInputChange(); + onShippingPromoChange({ minSubtotal: parseNumber(event.target.value, 0) }); + }} + onBlur={handleFieldBlur} />
@@ -293,8 +426,12 @@ export function ConfigPanel({ className={compactNumberClass} type="number" step="1" - value={Math.round(shippingPromo.maxDiscount ?? 0)} - onChange={(event) => onShippingPromoChange({ maxDiscount: parseNumber(event.target.value, 0) })} + value={shippingPromo.maxDiscount ?? 0} + onChange={(event) => { + onConfigInputChange(); + onShippingPromoChange({ maxDiscount: parseNumber(event.target.value, 0) }); + }} + onBlur={handleFieldBlur} /> @@ -303,79 +440,91 @@ export function ConfigPanel({
-
+
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), - }) - } - /> + {/* Header row */} +
+
$ Amount
+
Type
+
Value
+
+
+ {shippingTiers.map((tier, index) => { + const tierKey = tier.id ?? `tier-${index}`; + return ( +
+
+ { + onConfigInputChange(); + handleTierUpdate(index, { + threshold: parseNumber(event.target.value, 0), + }); + }} + onBlur={handleFieldBlur} + /> +
+
+ +
+
+ { + onConfigInputChange(); + handleTierUpdate(index, { value: parseNumber(event.target.value, 0) }); + }} + onBlur={handleFieldBlur} + /> +
+
+
+ +
-
- - -
-
- - - handleTierUpdate(index, { value: parseNumber(event.target.value, 0) }) - } - /> -
-
- -
-
- ))} + ); + })}
)} @@ -392,7 +541,11 @@ export function ConfigPanel({ type="number" step="0.01" value={merchantFeePercent} - onChange={(event) => onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent))} + onChange={(event) => { + onConfigInputChange(); + onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent)); + }} + onBlur={handleFieldBlur} />
@@ -402,7 +555,11 @@ export function ConfigPanel({ type="number" step="0.01" value={fixedCostPerOrder} - onChange={(event) => onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder))} + onChange={(event) => { + onConfigInputChange(); + onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder)); + }} + onBlur={handleFieldBlur} />
@@ -419,9 +576,11 @@ export function ConfigPanel({ type="number" step="0.0001" value={pointsPerDollar} - onChange={(event) => - onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) }) - } + onChange={(event) => { + onConfigInputChange(); + onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) }); + }} + onBlur={handleFieldBlur} />
@@ -431,9 +590,11 @@ export function ConfigPanel({ type="number" step="0.01" value={redemptionRate * 100} - onChange={(event) => - onPointsChange({ redemptionRate: parseNumber(event.target.value, redemptionRate * 100) / 100 }) - } + onChange={(event) => { + onConfigInputChange(); + onPointsChange({ redemptionRate: parseNumber(event.target.value, redemptionRate * 100) / 100 }); + }} + onBlur={handleFieldBlur} />
@@ -444,9 +605,11 @@ export function ConfigPanel({ type="number" step="0.0001" value={pointDollarValue} - onChange={(event) => - onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) }) - } + onChange={(event) => { + onConfigInputChange(); + onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) }); + }} + onBlur={handleFieldBlur} /> {recommendedPoints && ( @@ -456,18 +619,20 @@ export function ConfigPanel({ Use recommended )} - - 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 index a92e081..450986d 100644 --- a/inventory/src/components/discount-simulator/ResultsChart.tsx +++ b/inventory/src/components/discount-simulator/ResultsChart.tsx @@ -1,5 +1,5 @@ import { useMemo } from "react"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { DiscountSimulationBucket } from "@/types/discount-simulator"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -118,14 +118,20 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) { const options = useMemo(() => ({ responsive: true, + maintainAspectRatio: false, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, 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'>) => { @@ -155,7 +161,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) { max: 50, ticks: { stepSize: 5, - callback: (value: number | string) => `${Number(value).toFixed(0)}%`, + callback: (value: number | string) => `${Number(value).toFixed(0)}`, }, title: { display: true, @@ -167,8 +173,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) { ticks: { maxRotation: 90, minRotation: 90, - maxTicksLimit: undefined, // Show all labels - autoSkip: false, // Don't skip any labels + autoSkip: true, // Allow skipping labels if needed }, }, }, @@ -176,12 +181,9 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) { if (isLoading && !chartData) { return ( - - - Profit Trend - + - + ); @@ -192,12 +194,9 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) { } return ( - - - Profit Trend - + -
+
diff --git a/inventory/src/components/discount-simulator/ResultsTable.tsx b/inventory/src/components/discount-simulator/ResultsTable.tsx index e631abe..ab214de 100644 --- a/inventory/src/components/discount-simulator/ResultsTable.tsx +++ b/inventory/src/components/discount-simulator/ResultsTable.tsx @@ -7,6 +7,12 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { DiscountSimulationBucket } from "@/types/discount-simulator"; import { formatCurrency, formatNumber } from "@/utils/productUtils"; import { Skeleton } from "@/components/ui/skeleton"; @@ -58,24 +64,35 @@ interface ResultsTableProps { } 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)}%` }, + // Most important metrics first - prominently styled + { key: "totalRevenue", label: "Total Revenue", format: (value: number) => formatCurrency(value), important: true, isCalculated: true }, + { key: "totalCosts", label: "Total Cost", format: (value: number) => formatCurrency(value), important: true, isCalculated: true }, + { key: "profit", label: "Profit $", format: (value: number) => formatCurrency(value), important: true, isCalculated: true }, + { key: "profitPercent", label: "Profit %", format: (value: number) => `${(value * 100).toFixed(2)}%`, important: true, isCalculated: true }, + + // Order metrics - from database + { key: "orderCount", label: "Orders", format: (value: number) => formatNumber(value), important: false, isCalculated: false }, + { key: "weight", label: "Weight", format: (value: number) => `${(value * 100).toFixed(2)}%`, important: false, isCalculated: false }, + { key: "orderValue", label: "Order Value", format: (value: number) => formatCurrency(value), important: false, isCalculated: false }, + + // Customer costs + { key: "customerItemCost", label: "Cust Item Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + { key: "customerShipCost", label: "Cust Ship Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + + // Discounts + { key: "productDiscountAmount", label: "Prod Discount", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + { key: "promoProductDiscount", label: "Promo Prod Disc", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + { key: "shipPromoDiscount", label: "Ship Promo Disc", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + + // Shipping + { key: "shippingAfterAuto", label: "Ship Charge", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + { key: "actualShippingCost", label: "Actual Ship Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: false }, + + // Cost breakdown + { key: "productCogs", label: "Product COGS", format: (value: number) => formatCurrency(value), important: false, isCalculated: false }, + { key: "merchantFees", label: "Merchant Fees", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + { key: "pointsCost", label: "Points Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, + { key: "fixedCosts", label: "Fixed Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true }, ]; const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => { @@ -101,18 +118,21 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) { } return ( - - - Profitability by Order Value - - + +
- +
+ + + {buckets.map((bucket) => ( + + ))} + - Metric + Metric {buckets.map((bucket) => ( - + {formatRangeUpperBound(bucket)} ))} @@ -127,8 +147,23 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) { ) : ( rowLabels.map((row) => ( - - {row.label} + + +
+ {/* Subtle indicator for data source */} + + + + + + +

{row.isCalculated ? 'Calculated value' : 'Database value'}

+
+
+
+ {row.label} +
+
{buckets.map((bucket) => { const value = bucket[row.key as keyof DiscountSimulationBucket] as number; const formattedValue = row.format(value); @@ -137,9 +172,9 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) { if (row.key === 'profitPercent') { const backgroundColor = getProfitPercentageColor(value); return ( - + {formattedValue} @@ -149,7 +184,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) { } return ( - + {formattedValue} ); diff --git a/inventory/src/components/discount-simulator/SummaryCard.tsx b/inventory/src/components/discount-simulator/SummaryCard.tsx index 8a5084a..0189faa 100644 --- a/inventory/src/components/discount-simulator/SummaryCard.tsx +++ b/inventory/src/components/discount-simulator/SummaryCard.tsx @@ -1,8 +1,9 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { formatCurrency, formatNumber } from "@/utils/productUtils"; +import { Card, CardContent } from "@/components/ui/card"; +import { formatCurrency } from "@/utils/productUtils"; import { DiscountSimulationResponse } from "@/types/discount-simulator"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; +import { Separator } from "@/components/ui/separator"; // Utility function to interpolate between two colors const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => { @@ -58,26 +59,16 @@ const formatPercent = (value: number) => { export function SummaryCard({ result, isLoading }: SummaryCardProps) { if (isLoading && !result) { return ( - - - Simulation Summary - + -
-
-
- - -
- +
+
+ +
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- - -
- ))} +
+ +
@@ -99,59 +90,31 @@ export function SummaryCard({ result, isLoading }: SummaryCardProps) { : "secondary"; return ( - - - Simulation Summary - + -
- {/* Left side - Main profit metrics */} -
-
-

Weighted Profit per Order

-
{weightedProfitAmount}
-
-
- {totals ? ( - - {weightedProfitPercent} - - ) : ( - - {weightedProfitPercent} - - )} -
+
+
+

Weighted Average Profit

+ {totals ? ( + + {weightedProfitPercent} + + ) : ( + + {weightedProfitPercent} + + )} +
+ +
+

Weighted Profit Per Order

+ + {weightedProfitAmount} +
- - {/* 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 b279b04..cdf4b36 100644 --- a/inventory/src/components/layout/AppSidebar.tsx +++ b/inventory/src/components/layout/AppSidebar.tsx @@ -84,7 +84,10 @@ const inventoryItems = [ icon: BarChart2, url: "/analytics", permission: "access:analytics" - }, + } +]; + +const toolsItems = [ { title: "Discount Simulator", icon: Percent, @@ -130,12 +133,12 @@ export function AppSidebar() { }; // Check if user has access to any items in a section - const hasAccessToSection = (items: typeof inventoryItems): boolean => { + const hasAccessToSection = (items: any[]): boolean => { if (user?.is_admin) return true; return items.some(item => user?.permissions?.includes(item.permission)); }; - const renderMenuItems = (items: typeof inventoryItems) => { + const renderMenuItems = (items: any[]) => { return items.map((item) => { const isActive = location.pathname === item.url || @@ -219,6 +222,18 @@ export function AppSidebar() { )} + {/* Tools Section */} + {hasAccessToSection(toolsItems) && ( + + Tools + + + {renderMenuItems(toolsItems)} + + + + )} + {/* Product Setup Section */} {hasAccessToSection(productSetupItems) && ( diff --git a/inventory/src/pages/DiscountSimulator.tsx b/inventory/src/pages/DiscountSimulator.tsx index 795ccd9..338911c 100644 --- a/inventory/src/pages/DiscountSimulator.tsx +++ b/inventory/src/pages/DiscountSimulator.tsx @@ -22,11 +22,12 @@ 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 STORAGE_KEY = 'discount-simulator-config-v1'; -const initialDateRange: DateRange = { +const getDefaultDateRange = (): 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); @@ -49,7 +50,7 @@ const defaultShippingPromo = { export function DiscountSimulator() { const { toast } = useToast(); - const [dateRange, setDateRange] = useState(initialDateRange); + const [dateRange, setDateRange] = useState(() => getDefaultDateRange()); const [selectedPromoId, setSelectedPromoId] = useState(undefined); const [productPromo, setProductPromo] = useState(defaultProductPromo); const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo); @@ -64,15 +65,25 @@ export function DiscountSimulator() { const [pointsTouched, setPointsTouched] = useState(false); const [simulationResult, setSimulationResult] = useState(undefined); const [isSimulating, setIsSimulating] = useState(false); + const [hasLoadedConfig, setHasLoadedConfig] = useState(false); + const [loadedFromStorage, setLoadedFromStorage] = useState(false); const initialRunRef = useRef(false); const skipAutoRunRef = useRef(false); const latestPayloadKeyRef = useRef(''); const pendingCountRef = useRef(0); + const promoDateBounds = useMemo(() => { + const { from, to } = ensureDateRange(dateRange); + return { + startDate: startOfDay(from).toISOString(), + endDate: endOfDay(to).toISOString(), + }; + }, [dateRange]); + const promosQuery = useQuery({ - queryKey: ['discount-promos'], + queryKey: ['discount-promos', promoDateBounds.startDate, promoDateBounds.endDate], queryFn: async () => { - const response = await acotService.getDiscountPromos() as { promos?: Array> }; + const response = await acotService.getDiscountPromos(promoDateBounds) as { promos?: Array> }; const rawList = Array.isArray(response?.promos) ? response.promos : []; @@ -84,7 +95,14 @@ export function DiscountSimulator() { (promo.description as string | undefined) || (promo.promo_description_online as string | undefined) || (promo.promo_description_private as string | undefined) || + (promo.description_online as string | undefined) || + (promo.description_private as string | undefined) || (typeof codeValue === 'string' ? codeValue : ''); + const privateDescriptionValue = + (promo.privateDescription as string | undefined) || + (promo.promo_description_private as string | undefined) || + (promo.description_private as string | undefined) || + ''; 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; @@ -94,6 +112,7 @@ export function DiscountSimulator() { id: Number(idValue) || 0, code: typeof codeValue === 'string' ? codeValue : '', description: descriptionValue ?? '', + privateDescription: privateDescriptionValue, dateStart: dateStartValue, dateEnd: dateEndValue, usageCount: Number(usageValue) || 0, @@ -103,6 +122,8 @@ export function DiscountSimulator() { }, }); + const promosLoading = !promosQuery.data && promosQuery.isLoading; + const createPayload = useCallback((): DiscountSimulationRequest => { const { from, to } = ensureDateRange(dateRange); @@ -117,7 +138,11 @@ export function DiscountSimulator() { }, productPromo, shippingPromo, - shippingTiers, + shippingTiers: shippingTiers.map((tier) => { + const { id, ...rest } = tier; + void id; + return rest; + }), merchantFeePercent, fixedCostPerOrder, pointsConfig: { @@ -195,6 +220,86 @@ export function DiscountSimulator() { mutate(payload); }, [createPayload, mutate]); + useEffect(() => { + if (typeof window === 'undefined') { + setHasLoadedConfig(true); + return; + } + + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + setHasLoadedConfig(true); + return; + } + + try { + const parsed = JSON.parse(raw) as { + dateRange?: { start?: string | null; end?: string | null }; + selectedPromoId?: number | null; + productPromo?: typeof defaultProductPromo; + shippingPromo?: typeof defaultShippingPromo; + shippingTiers?: ShippingTierConfig[]; + merchantFeePercent?: number; + fixedCostPerOrder?: number; + pointsConfig?: typeof pointsConfig; + }; + + skipAutoRunRef.current = true; + + if (parsed.dateRange) { + const defaultRange = getDefaultDateRange(); + const fromValue = parsed.dateRange.start ? new Date(parsed.dateRange.start) : undefined; + const toValue = parsed.dateRange.end ? new Date(parsed.dateRange.end) : undefined; + setDateRange({ + from: fromValue ?? defaultRange.from, + to: toValue ?? defaultRange.to, + }); + } + + if (typeof parsed.selectedPromoId === 'number') { + setSelectedPromoId(parsed.selectedPromoId); + } else if (parsed.selectedPromoId === null) { + setSelectedPromoId(undefined); + } + + if (parsed.productPromo) { + setProductPromo(parsed.productPromo); + } + + if (parsed.shippingPromo) { + setShippingPromo(parsed.shippingPromo); + } + + if (Array.isArray(parsed.shippingTiers)) { + setShippingTiers(parsed.shippingTiers); + } + + if (typeof parsed.merchantFeePercent === 'number') { + setMerchantFeePercent(parsed.merchantFeePercent); + } + + if (typeof parsed.fixedCostPerOrder === 'number') { + setFixedCostPerOrder(parsed.fixedCostPerOrder); + } + + if (parsed.pointsConfig) { + setPointsConfig(parsed.pointsConfig); + setPointsTouched(true); + } + + setLoadedFromStorage(true); + } catch (error) { + console.error('Failed to load discount simulator config', error); + skipAutoRunRef.current = false; + } finally { + setHasLoadedConfig(true); + } + }, []); + + const handleConfigInputChange = useCallback(() => { + skipAutoRunRef.current = true; + }, []); + const serializedConfig = useMemo(() => { const { from, to } = ensureDateRange(dateRange); return JSON.stringify({ @@ -212,6 +317,18 @@ export function DiscountSimulator() { }); }, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointsConfig]); + useEffect(() => { + if (!hasLoadedConfig) { + return; + } + + if (typeof window === 'undefined') { + return; + } + + window.localStorage.setItem(STORAGE_KEY, serializedConfig); + }, [serializedConfig, hasLoadedConfig]); + useEffect(() => { if (!initialRunRef.current) { initialRunRef.current = true; @@ -225,6 +342,25 @@ export function DiscountSimulator() { runSimulation(); }, [serializedConfig, runSimulation]); + useEffect(() => { + if (!loadedFromStorage) { + return; + } + + if (typeof window === 'undefined') { + skipAutoRunRef.current = false; + runSimulation(); + return; + } + + const timeoutId = window.setTimeout(() => { + skipAutoRunRef.current = false; + runSimulation(); + }, 0); + + return () => window.clearTimeout(timeoutId); + }, [loadedFromStorage, runSimulation]); + const recommendedPoints = simulationResult?.totals ? { pointsPerDollar: simulationResult.totals.pointsPerDollar, @@ -248,6 +384,37 @@ export function DiscountSimulator() { } }; + const resetConfig = useCallback(() => { + skipAutoRunRef.current = true; + const defaultRange = getDefaultDateRange(); + + setDateRange(defaultRange); + setSelectedPromoId(undefined); + setProductPromo(defaultProductPromo); + setShippingPromo(defaultShippingPromo); + setShippingTiers([]); + setMerchantFeePercent(DEFAULT_MERCHANT_FEE); + setFixedCostPerOrder(DEFAULT_FIXED_COST); + setPointsConfig({ + pointsPerDollar: 0, + redemptionRate: 0, + pointDollarValue: DEFAULT_POINT_VALUE, + }); + setPointsTouched(false); + setSimulationResult(undefined); + + if (typeof window !== 'undefined') { + window.localStorage.removeItem(STORAGE_KEY); + window.setTimeout(() => { + skipAutoRunRef.current = false; + runSimulation(); + }, 0); + } else { + skipAutoRunRef.current = false; + runSimulation(); + } + }, [runSimulation]); + const promos = promosQuery.data ?? []; const isLoading = isSimulating && !simulationResult; @@ -265,6 +432,7 @@ export function DiscountSimulator() { dateRange={dateRange} onDateRangeChange={(range) => range && setDateRange(range)} promos={promos} + promoLoading={promosLoading} selectedPromoId={selectedPromoId} onPromoChange={setSelectedPromoId} productPromo={productPromo} @@ -281,27 +449,30 @@ export function DiscountSimulator() { redemptionRate={pointsConfig.redemptionRate} pointDollarValue={pointsConfig.pointDollarValue} onPointsChange={handlePointsChange} + onConfigInputChange={handleConfigInputChange} + onResetConfig={resetConfig} onRunSimulation={() => runSimulation()} isRunning={isSimulating} recommendedPoints={recommendedPoints} onApplyRecommendedPoints={handleApplyRecommendedPoints} + result={simulationResult} />
{/* Right Side - Results */} -
- {/* Top Right - Summary (Full Width) */} -
- -
- - {/* Middle Right - Chart (Full Width) */} -
- +
+ {/* Top Right - Summary and Chart */} +
+
+ +
+
+ +
{/* Bottom Right - Table */} -
+
diff --git a/inventory/src/services/dashboard/acotService.js b/inventory/src/services/dashboard/acotService.js index 6f04f74..d0fa1a2 100644 --- a/inventory/src/services/dashboard/acotService.js +++ b/inventory/src/services/dashboard/acotService.js @@ -179,14 +179,19 @@ export const acotService = { ); }, - getDiscountPromos: async () => { - const cacheKey = 'discount_promos'; + getDiscountPromos: async (params = {}) => { + const { startDate, endDate } = params || {}; + const cacheKey = `discount_promos_${startDate || 'none'}_${endDate || 'none'}`; return deduplicatedRequest( cacheKey, () => retryRequest( async () => { const response = await acotApi.get('/api/acot/discounts/promos', { + params: { + ...(startDate ? { startDate } : {}), + ...(endDate ? { endDate } : {}), + }, timeout: 60000, }); return response.data; diff --git a/inventory/src/types/dashboard-shims.d.ts b/inventory/src/types/dashboard-shims.d.ts index 99300ac..4e85bf3 100644 --- a/inventory/src/types/dashboard-shims.d.ts +++ b/inventory/src/types/dashboard-shims.d.ts @@ -5,7 +5,7 @@ declare module "@/services/dashboard/acotService" { getProducts: (params: unknown) => Promise; getFinancials: (params: unknown) => Promise; getProjection: (params: unknown) => Promise; - getDiscountPromos: () => Promise; + getDiscountPromos: (params?: { startDate?: string; endDate?: string }) => 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 index e133c12..ffd3ddb 100644 --- a/inventory/src/types/discount-simulator.ts +++ b/inventory/src/types/discount-simulator.ts @@ -2,6 +2,7 @@ export interface DiscountPromoOption { id: number; code: string; description: string; + privateDescription: string; dateStart: string | null; dateEnd: string | null; usageCount: number; @@ -15,6 +16,7 @@ export interface ShippingTierConfig { threshold: number; mode: ShippingTierMode; value: number; + id?: string; } export interface DiscountSimulationBucket {