Regroup sidebar, discount sim layout updates and fixes

This commit is contained in:
2025-09-25 11:44:15 -04:00
parent 6e30ba60ff
commit a161f4533d
10 changed files with 634 additions and 259 deletions

View File

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

View File

@@ -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) => ({
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.description || promo.code,
description: promo.description,
}));
}, [promos]);
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<ShippingTierConfig>) => {
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,19 +231,22 @@ export function ConfigPanel({
const showProductAdjustments = productPromo.type !== "none";
const showShippingAdjustments = shippingPromo.type !== "none";
const handleFieldBlur = useCallback(() => {
setTimeout(onRunSimulation, 0);
}, [onRunSimulation]);
return (
<Card className="w-full">
<CardContent className="flex flex-col gap-4 px-4 py-4">
<div className="space-y-6">
<section className={sectionClass}>
<span className={sectionTitleClass}>Filters</span>
<span className={sectionTitleClass}>Calculated Metrics Filters</span>
<div className={fieldRowClass}>
<div className={fieldClass}>
<Label className={labelClass}>Date range</Label>
<DateRangePicker
value={dateRange}
onChange={(range) => onDateRangeChange(range)}
className="h-9"
/>
</div>
<div className={fieldClass}>
@@ -173,7 +263,13 @@ export function ConfigPanel({
}}
>
<SelectTrigger className={`${compactTriggerClass} w-full`}>
{promoLoading ? (
<div className="flex w-full items-center">
<Skeleton className="h-4 w-3/4" />
</div>
) : (
<SelectValue placeholder="All promos" />
)}
</SelectTrigger>
<SelectContent className="max-h-56">
<SelectItem value="__all__">All promos</SelectItem>
@@ -191,6 +287,27 @@ export function ConfigPanel({
</Select>
</div>
</div>
{/* Calculated Metrics */}
{result?.totals && (
<div className="flex flex-wrap gap-2 mt-3">
<Badge variant="secondary" className="text-xs">
{formatNumber(result.totals.orders)} orders
</Badge>
<Badge variant="secondary" className="text-xs">
{formatPercent(result.totals.productDiscountRate)} avg discount
</Badge>
<Badge variant="secondary" className="text-xs">
{result.totals.pointsPerDollar.toFixed(4)} pts/$
</Badge>
<Badge variant="secondary" className="text-xs">
{formatPercent(result.totals.redemptionRate)} redeemed
</Badge>
<Badge variant="secondary" className="text-xs">
{formatCurrency(result.totals.pointDollarValue, 4)} pt value
</Badge>
</div>
)}
</section>
<Separator />
<section className={sectionClass}>
@@ -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}
/>
</div>
<div className={fieldClass}>
@@ -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}
/>
</div>
</div>
@@ -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}
/>
</div>
<div className={fieldClass}>
@@ -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}
/>
</div>
</div>
@@ -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}
/>
</div>
</>
@@ -303,79 +440,91 @@ export function ConfigPanel({
</section>
<section className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between">
<span className={sectionTitleClass}>Shipping tiers</span>
<Button variant="outline" size="sm" onClick={handleTierAdd}>
<Button variant="outline" size="sm" onClick={handleTierAdd} className="flex items-center gap-1">
<PlusIcon className="w-3 h-3" />
Add tier
</Button>
</div>
{shippingTiers.length === 0 ? (
<p className="text-xs text-muted-foreground">Add tiers to model automatic shipping discounts.</p>
) : (
<ScrollArea className="max-h-32">
<ScrollArea className="">
<div className="flex flex-col gap-2 pr-1">
{shippingTiers.map((tier, index) => (
{/* Header row */}
<div className="grid gap-2 px-2 py-1 text-xs font-medium text-muted-foreground sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)_auto]">
<div>$ Amount</div>
<div>Type</div>
<div>Value</div>
<div className="w-1.5"></div>
</div>
{shippingTiers.map((tier, index) => {
const tierKey = tier.id ?? `tier-${index}`;
return (
<div
key={`${tier.threshold}-${index}`}
className="grid gap-2 rounded border px-2 py-2 text-xs sm:grid-cols-[repeat(3,minmax(0,1fr))_auto] sm:items-center"
key={tierKey}
className="relative grid gap-2 rounded border px-2 py-2 text-xs sm:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)_minmax(0,1fr)_auto] sm:items-end"
>
<span className="text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground sm:col-span-4">
Tier {index + 1}
</span>
<div className={fieldClass}>
<Label className={labelClass}>Threshold ($)</Label>
<div>
<Input
className={compactNumberClass}
type="number"
step="1"
value={tier.threshold}
onChange={(event) =>
onChange={(event) => {
onConfigInputChange();
handleTierUpdate(index, {
threshold: parseNumber(event.target.value, 0),
})
}
});
}}
onBlur={handleFieldBlur}
/>
</div>
<div className={fieldClass}>
<Label className={labelClass}>Mode</Label>
<div>
<Select
value={tier.mode}
onValueChange={(value) =>
handleTierUpdate(index, { mode: value as ShippingTierConfig["mode"] })
}
onValueChange={(value) => {
onConfigInputChange();
handleTierUpdate(index, { mode: value as ShippingTierConfig["mode"] });
}}
>
<SelectTrigger className={`${compactTriggerClass} w-full`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percentage">% off shipping</SelectItem>
<SelectItem value="percentage">% off</SelectItem>
<SelectItem value="flat">Flat rate</SelectItem>
</SelectContent>
</Select>
</div>
<div className={fieldClass}>
<Label className={labelClass}>{tier.mode === "flat" ? "Flat rate" : "Percent"}</Label>
<div>
<Input
className={compactNumberClass}
type="number"
step="1"
value={Math.round(tier.value)}
onChange={(event) =>
handleTierUpdate(index, { value: parseNumber(event.target.value, 0) })
}
value={tier.value}
onChange={(event) => {
onConfigInputChange();
handleTierUpdate(index, { value: parseNumber(event.target.value, 0) });
}}
onBlur={handleFieldBlur}
/>
</div>
<div className="flex justify-end sm:col-span-1">
<div className="w-1.5"></div>
<div className="flex justify-end sm:col-span-1 absolute -right-0.5 top-1/2 -translate-y-1/2">
<Button
variant="ghost"
size="sm"
onClick={() => handleTierRemove(index)}
className="p-1"
>
Remove
<X className="w-3 h-3" />
</Button>
</div>
</div>
))}
);
})}
</div>
</ScrollArea>
)}
@@ -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}
/>
</div>
<div className={fieldClass}>
@@ -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}
/>
</div>
</div>
@@ -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}
/>
</div>
<div className={fieldClass}>
@@ -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}
/>
</div>
</div>
@@ -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}
/>
</div>
{recommendedPoints && (
@@ -456,18 +619,20 @@ export function ConfigPanel({
Use recommended
</Button>
)}
<span>
Recommended: {recommendedPoints.pointsPerDollar.toFixed(4)} pts/$ · {(recommendedPoints.redemptionRate * 100).toFixed(2)}% redeemed · ${recommendedPoints.pointDollarValue.toFixed(4)} per point
</span>
</div>
)}
</div>
</section>
</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onResetConfig} disabled={isRunning}>
Reset
</Button>
<Button size="sm" className="sm:w-auto" onClick={onRunSimulation} disabled={isRunning}>
{isRunning ? "Running simulation..." : "Run simulation"}
</Button>
</div>
</CardContent>
</Card>
);

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Profit Trend</CardTitle>
</CardHeader>
<Card className="pt-6">
<CardContent>
<Skeleton className="h-64 w-full" />
<Skeleton className="h-72 w-full" />
</CardContent>
</Card>
);
@@ -192,12 +194,9 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
}
return (
<Card>
<CardHeader>
<CardTitle>Profit Trend</CardTitle>
</CardHeader>
<Card className="pt-6">
<CardContent>
<div className="h-72">
<div className="h-72 w-full overflow-hidden">
<Line data={chartData} options={options} />
</div>
</CardContent>

View File

@@ -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 (
<Card>
<CardHeader className="px-4 py-3">
<CardTitle className="text-base font-semibold">Profitability by Order Value</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0A">
<Card className="pt-6">
<CardContent className="px-4 pb-4 pt-0">
<div className="overflow-x-auto max-w-full">
<Table className="text-sm w-auto">
<Table className="text-sm table-fixed">
<colgroup>
<col style={{ width: '152px' }} />
{buckets.map((bucket) => (
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} />
))}
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="font-medium sticky left-0 bg-background z-10 w-[140px]">Metric</TableHead>
<TableHead className="font-medium sticky left-0 bg-background z-10 px-3 py-2 border-r">Metric</TableHead>
{buckets.map((bucket) => (
<TableHead key={bucket.key} className="text-center whitespace-nowrap w-[100px]">
<TableHead key={bucket.key} className="text-center whitespace-nowrap px-1 py-2 font-medium">
{formatRangeUpperBound(bucket)}
</TableHead>
))}
@@ -127,8 +147,23 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
</TableRow>
) : (
rowLabels.map((row) => (
<TableRow key={row.key}>
<TableCell className="font-medium sticky left-0 bg-background z-10 w-[140px]">{row.label}</TableCell>
<TableRow key={row.key} className={row.important ? "bg-muted/30 border-l-4 border-l-primary" : ""}>
<TableCell className={`font-medium sticky left-0 bg-background z-10 px-3 py-2 border-r ${row.important ? 'font-semibold text-primary' : ''}`}>
<div className="flex items-center gap-2">
{/* Subtle indicator for data source */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className={`inline-block w-2 h-2 rounded-full ${row.isCalculated ? 'bg-blue-400' : 'bg-green-400'}`} />
</TooltipTrigger>
<TooltipContent>
<p>{row.isCalculated ? 'Calculated value' : 'Database value'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<span>{row.label}</span>
</div>
</TableCell>
{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 (
<TableCell key={`${row.key}-${bucket.key}`} className="text-center whitespace-nowrap w-[100px]">
<TableCell key={`${row.key}-${bucket.key}`} className={`text-center whitespace-nowrap px-1 py-1 ${row.important ? 'font-semibold' : ''}`}>
<span
className="inline-block px-2 py-1 rounded text-white font-medium"
className="inline-block px-1.5 py-0.5 rounded text-white font-medium"
style={{ backgroundColor }}
>
{formattedValue}
@@ -149,7 +184,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
}
return (
<TableCell key={`${row.key}-${bucket.key}`} className="text-center whitespace-nowrap w-[100px]">
<TableCell key={`${row.key}-${bucket.key}`} className={`text-center whitespace-nowrap px-1 py-1 ${row.important ? 'font-semibold text-primary' : ''}`}>
{formattedValue}
</TableCell>
);

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Simulation Summary</CardTitle>
</CardHeader>
<Card className="pt-6">
<CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-6">
<div className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-8 w-24" />
<div className="h-72 flex flex-col items-center justify-center gap-8">
<div className="text-center space-y-2">
<Skeleton className="h-4 w-24 mx-auto" />
<Skeleton className="h-10 w-20 mx-auto" />
</div>
<Skeleton className="h-8 w-16" />
</div>
<div className="grid grid-cols-5 gap-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="text-center space-y-1">
<Skeleton className="h-3 w-16 mx-auto" />
<Skeleton className="h-4 w-12 mx-auto" />
</div>
))}
<div className="text-center space-y-2">
<Skeleton className="h-4 w-32 mx-auto" />
<Skeleton className="h-8 w-24 mx-auto" />
</div>
</div>
</CardContent>
@@ -99,59 +90,31 @@ export function SummaryCard({ result, isLoading }: SummaryCardProps) {
: "secondary";
return (
<Card>
<CardHeader>
<CardTitle>Simulation Summary</CardTitle>
</CardHeader>
<Card className="pt-6">
<CardContent>
<div className="flex items-center justify-between">
{/* Left side - Main profit metrics */}
<div className="flex items-center gap-6">
<div>
<p className="text-sm text-muted-foreground">Weighted Profit per Order</p>
<div className="text-3xl font-semibold">{weightedProfitAmount}</div>
</div>
<div className="flex items-center gap-2">
<div className="h-72 flex flex-col items-center justify-center gap-5">
<div className="text-center">
<p className="text-sm text-muted-foreground mb-3">Weighted Average Profit</p>
{totals ? (
<span
className="inline-block px-3 py-1 rounded text-white font-medium text-lg"
className="inline-block px-4 py-2 rounded text-white font-semibold text-2xl"
style={{ backgroundColor: profitPercentageColor }}
>
{weightedProfitPercent}
</span>
) : (
<Badge variant={weightedBadgeVariant} className="text-lg py-1 px-3">
<Badge variant={weightedBadgeVariant} className="text-2xl py-2 px-4 font-semibold">
{weightedProfitPercent}
</Badge>
)}
</div>
</div>
{/* Right side - Secondary metrics */}
{totals && (
<div className="grid grid-cols-5 gap-6 text-sm">
<Separator orientation="horizontal" />
<div className="text-center">
<p className="text-muted-foreground mb-1">Orders</p>
<p className="font-medium">{formatNumber(totals.orders)}</p>
<p className="text-sm text-muted-foreground mb-3">Weighted Profit Per Order</p>
<Badge variant="secondary" className="text-2xl py-2 px-4 font-semibold">
{weightedProfitAmount}
</Badge>
</div>
<div className="text-center">
<p className="text-muted-foreground mb-1">Avg Discount</p>
<p className="font-medium">{formatPercent(totals.productDiscountRate)}</p>
</div>
<div className="text-center">
<p className="text-muted-foreground mb-1">Points/$</p>
<p className="font-medium">{totals.pointsPerDollar.toFixed(4)}</p>
</div>
<div className="text-center">
<p className="text-muted-foreground mb-1">Redeemed</p>
<p className="font-medium">{formatPercent(totals.redemptionRate)}</p>
</div>
<div className="text-center">
<p className="text-muted-foreground mb-1">Point Value</p>
<p className="font-medium">{formatCurrency(totals.pointDollarValue, 4)}</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>

View File

@@ -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() {
</SidebarGroup>
)}
{/* Tools Section */}
{hasAccessToSection(toolsItems) && (
<SidebarGroup>
<SidebarGroupLabel>Tools</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(toolsItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Product Setup Section */}
{hasAccessToSection(productSetupItems) && (
<SidebarGroup>

View File

@@ -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<DateRange>(initialDateRange);
const [dateRange, setDateRange] = useState<DateRange>(() => getDefaultDateRange());
const [selectedPromoId, setSelectedPromoId] = useState<number | undefined>(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<DiscountSimulationResponse | undefined>(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<DiscountPromoOption[]>({
queryKey: ['discount-promos'],
queryKey: ['discount-promos', promoDateBounds.startDate, promoDateBounds.endDate],
queryFn: async () => {
const response = await acotService.getDiscountPromos() as { promos?: Array<Record<string, unknown>> };
const response = await acotService.getDiscountPromos(promoDateBounds) as { promos?: Array<Record<string, unknown>> };
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}
/>
</div>
{/* Right Side - Results */}
<div className="space-y-4 min-w-0 flex-1">
{/* Top Right - Summary (Full Width) */}
<div className="w-full">
<div className="space-y-4 min-w-0 flex-1 overflow-hidden">
{/* Top Right - Summary and Chart */}
<div className="grid gap-4 lg:grid-cols-[160px,1fr] min-w-0">
<div className="w-full min-w-0">
<SummaryCard result={simulationResult} isLoading={isLoading} />
</div>
{/* Middle Right - Chart (Full Width) */}
<div className="w-full">
<div className="w-full min-w-0 overflow-hidden">
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
</div>
</div>
{/* Bottom Right - Table */}
<div className="w-full">
<div className="w-full min-w-0 overflow-hidden">
<ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
</div>
</div>

View File

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

View File

@@ -5,7 +5,7 @@ declare module "@/services/dashboard/acotService" {
getProducts: (params: unknown) => Promise<unknown>;
getFinancials: (params: unknown) => Promise<unknown>;
getProjection: (params: unknown) => Promise<unknown>;
getDiscountPromos: () => Promise<unknown>;
getDiscountPromos: (params?: { startDate?: string; endDate?: string }) => Promise<unknown>;
simulateDiscounts: (payload: unknown) => Promise<unknown>;
[key: string]: (...args: never[]) => Promise<unknown> | unknown;
};

View File

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