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; connection = conn;
const releaseConnection = release; 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 = ` const sql = `
SELECT SELECT
p.promo_id AS id, p.promo_id AS id,
@@ -105,18 +118,25 @@ router.get('/promos', async (req, res) => {
WHERE discount_type = 10 AND discount_active = 1 WHERE discount_type = 10 AND discount_active = 1
GROUP BY discount_code GROUP BY discount_code
) u ON u.discount_code = p.promo_id ) u ON u.discount_code = p.promo_id
WHERE p.date_end >= DATE_SUB(CURDATE(), INTERVAL 3 YEAR) WHERE p.date_start IS NOT NULL
ORDER BY p.date_end DESC, p.date_start DESC 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 LIMIT 200
`; `;
const [rows] = await connection.execute(sql); const [rows] = await connection.execute(sql, [rangeStartSql, rangeEndSql]);
releaseConnection(); releaseConnection();
const promos = rows.map(row => ({ const promos = rows.map(row => ({
id: Number(row.id), id: Number(row.id),
code: row.code, code: row.code,
description: row.description_online || row.description_private || '', 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, dateStart: row.date_start,
dateEnd: row.date_end, dateEnd: row.date_end,
usageCount: Number(row.usage_count || 0) 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 { DateRange } from "react-day-picker";
import { DateRangePicker } from "@/components/ui/date-range-picker"; import { DateRangePicker } from "@/components/ui/date-range-picker";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
@@ -7,13 +8,18 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { ScrollArea } from "@/components/ui/scroll-area"; 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 { 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 { interface ConfigPanelProps {
dateRange: DateRange; dateRange: DateRange;
onDateRangeChange: (range: DateRange | undefined) => void; onDateRangeChange: (range: DateRange | undefined) => void;
promos: DiscountPromoOption[]; promos: DiscountPromoOption[];
promoLoading?: boolean;
selectedPromoId?: number; selectedPromoId?: number;
onPromoChange: (promoId: number | undefined) => void; onPromoChange: (promoId: number | undefined) => void;
productPromo: { productPromo: {
@@ -43,6 +49,8 @@ interface ConfigPanelProps {
redemptionRate?: number; redemptionRate?: number;
pointDollarValue?: number; pointDollarValue?: number;
}) => void; }) => void;
onConfigInputChange: () => void;
onResetConfig: () => void;
onRunSimulation: () => void; onRunSimulation: () => void;
isRunning: boolean; isRunning: boolean;
recommendedPoints?: { recommendedPoints?: {
@@ -51,6 +59,7 @@ interface ConfigPanelProps {
pointDollarValue: number; pointDollarValue: number;
}; };
onApplyRecommendedPoints?: () => void; onApplyRecommendedPoints?: () => void;
result?: DiscountSimulationResponse;
} }
function parseNumber(value: string, fallback = 0) { function parseNumber(value: string, fallback = 0) {
@@ -58,6 +67,36 @@ function parseNumber(value: string, fallback = 0) {
return Number.isFinite(parsed) ? parsed : fallback; 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({ export function ConfigPanel({
dateRange, dateRange,
onDateRangeChange, onDateRangeChange,
@@ -78,24 +117,69 @@ export function ConfigPanel({
redemptionRate, redemptionRate,
pointDollarValue, pointDollarValue,
onPointsChange, onPointsChange,
onConfigInputChange,
onResetConfig,
promoLoading = false,
onRunSimulation, onRunSimulation,
isRunning, isRunning,
recommendedPoints, recommendedPoints,
onApplyRecommendedPoints onApplyRecommendedPoints,
result
}: ConfigPanelProps) { }: ConfigPanelProps) {
const promoOptions = useMemo(() => { const promoOptions = useMemo(() => {
return promos.map((promo) => ({ if (!Array.isArray(promos) || promos.length === 0) {
value: promo.id.toString(), return [];
label: promo.description || promo.code, }
description: promo.description,
})); const rangeStartDate = dateRange?.from ? startOfDay(dateRange.from) : undefined;
}, [promos]); 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<ShippingTierConfig>) => { const handleTierUpdate = (index: number, update: Partial<ShippingTierConfig>) => {
const tiers = [...shippingTiers]; const tiers = [...shippingTiers];
const current = tiers[index];
if (!current) {
return;
}
const tierId = current.id ?? generateTierId();
tiers[index] = { tiers[index] = {
...tiers[index], ...current,
...update, ...update,
id: tierId,
}; };
const sorted = tiers const sorted = tiers
.filter((tier) => tier != null) .filter((tier) => tier != null)
@@ -109,11 +193,13 @@ export function ConfigPanel({
}; };
const handleTierRemove = (index: number) => { const handleTierRemove = (index: number) => {
onConfigInputChange();
const tiers = shippingTiers.filter((_, i) => i !== index); const tiers = shippingTiers.filter((_, i) => i !== index);
onShippingTiersChange(tiers); onShippingTiersChange(tiers);
}; };
const handleTierAdd = () => { const handleTierAdd = () => {
onConfigInputChange();
const lastThreshold = shippingTiers[shippingTiers.length - 1]?.threshold ?? 0; const lastThreshold = shippingTiers[shippingTiers.length - 1]?.threshold ?? 0;
const tiers = [ const tiers = [
...shippingTiers, ...shippingTiers,
@@ -121,6 +207,7 @@ export function ConfigPanel({
threshold: lastThreshold, threshold: lastThreshold,
mode: "percentage" as const, mode: "percentage" as const,
value: 0, value: 0,
id: generateTierId(),
}, },
]; ];
onShippingTiersChange(tiers); onShippingTiersChange(tiers);
@@ -144,39 +231,48 @@ export function ConfigPanel({
const showProductAdjustments = productPromo.type !== "none"; const showProductAdjustments = productPromo.type !== "none";
const showShippingAdjustments = shippingPromo.type !== "none"; const showShippingAdjustments = shippingPromo.type !== "none";
const handleFieldBlur = useCallback(() => {
setTimeout(onRunSimulation, 0);
}, [onRunSimulation]);
return ( return (
<Card className="w-full"> <Card className="w-full">
<CardContent className="flex flex-col gap-4 px-4 py-4"> <CardContent className="flex flex-col gap-4 px-4 py-4">
<div className="space-y-6"> <div className="space-y-6">
<section className={sectionClass}> <section className={sectionClass}>
<span className={sectionTitleClass}>Filters</span> <span className={sectionTitleClass}>Calculated Metrics Filters</span>
<div className={fieldRowClass}> <div className={fieldRowClass}>
<div className={fieldClass}> <div className={fieldClass}>
<Label className={labelClass}>Date range</Label> <Label className={labelClass}>Date range</Label>
<DateRangePicker <DateRangePicker
value={dateRange} value={dateRange}
onChange={(range) => onDateRangeChange(range)} onChange={(range) => onDateRangeChange(range)}
className="h-9"
/> />
</div> </div>
<div className={fieldClass}> <div className={fieldClass}>
<Label className={labelClass}>Promo code</Label> <Label className={labelClass}>Promo code</Label>
<Select <Select
value={selectedPromoId !== undefined ? selectedPromoId.toString() : undefined} value={selectedPromoId !== undefined ? selectedPromoId.toString() : undefined}
onValueChange={(value) => { onValueChange={(value) => {
if (value === '__all__') { if (value === '__all__') {
onPromoChange(undefined); onPromoChange(undefined);
return; return;
} }
const parsed = Number(value); const parsed = Number(value);
onPromoChange(Number.isNaN(parsed) ? undefined : parsed); onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
}} }}
> >
<SelectTrigger className={`${compactTriggerClass} w-full`}> <SelectTrigger className={`${compactTriggerClass} w-full`}>
<SelectValue placeholder="All promos" /> {promoLoading ? (
</SelectTrigger> <div className="flex w-full items-center">
<SelectContent className="max-h-56"> <Skeleton className="h-4 w-3/4" />
<SelectItem value="__all__">All promos</SelectItem> </div>
) : (
<SelectValue placeholder="All promos" />
)}
</SelectTrigger>
<SelectContent className="max-h-56">
<SelectItem value="__all__">All promos</SelectItem>
{promoOptions.map((promo) => ( {promoOptions.map((promo) => (
<SelectItem key={promo.value} value={promo.value}> <SelectItem key={promo.value} value={promo.value}>
<div className="flex flex-col text-xs"> <div className="flex flex-col text-xs">
@@ -191,6 +287,27 @@ export function ConfigPanel({
</Select> </Select>
</div> </div>
</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> </section>
<Separator /> <Separator />
<section className={sectionClass}> <section className={sectionClass}>
@@ -223,8 +340,12 @@ export function ConfigPanel({
className={compactNumberClass} className={compactNumberClass}
type="number" type="number"
step="1" step="1"
value={Math.round(productPromo.value ?? 0)} value={productPromo.value ?? 0}
onChange={(event) => onProductPromoChange({ value: parseNumber(event.target.value, 0) })} onChange={(event) => {
onConfigInputChange();
onProductPromoChange({ value: parseNumber(event.target.value, 0) });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
<div className={fieldClass}> <div className={fieldClass}>
@@ -233,8 +354,12 @@ export function ConfigPanel({
className={compactNumberClass} className={compactNumberClass}
type="number" type="number"
step="1" step="1"
value={Math.round(productPromo.minSubtotal ?? 0)} value={productPromo.minSubtotal ?? 0}
onChange={(event) => onProductPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })} onChange={(event) => {
onConfigInputChange();
onProductPromoChange({ minSubtotal: parseNumber(event.target.value, 0) });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
</div> </div>
@@ -272,8 +397,12 @@ export function ConfigPanel({
className={compactNumberClass} className={compactNumberClass}
type="number" type="number"
step="1" step="1"
value={Math.round(shippingPromo.value ?? 0)} value={shippingPromo.value ?? 0}
onChange={(event) => onShippingPromoChange({ value: parseNumber(event.target.value, 0) })} onChange={(event) => {
onConfigInputChange();
onShippingPromoChange({ value: parseNumber(event.target.value, 0) });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
<div className={fieldClass}> <div className={fieldClass}>
@@ -282,8 +411,12 @@ export function ConfigPanel({
className={compactNumberClass} className={compactNumberClass}
type="number" type="number"
step="1" step="1"
value={Math.round(shippingPromo.minSubtotal ?? 0)} value={shippingPromo.minSubtotal ?? 0}
onChange={(event) => onShippingPromoChange({ minSubtotal: parseNumber(event.target.value, 0) })} onChange={(event) => {
onConfigInputChange();
onShippingPromoChange({ minSubtotal: parseNumber(event.target.value, 0) });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
</div> </div>
@@ -293,8 +426,12 @@ export function ConfigPanel({
className={compactNumberClass} className={compactNumberClass}
type="number" type="number"
step="1" step="1"
value={Math.round(shippingPromo.maxDiscount ?? 0)} value={shippingPromo.maxDiscount ?? 0}
onChange={(event) => onShippingPromoChange({ maxDiscount: parseNumber(event.target.value, 0) })} onChange={(event) => {
onConfigInputChange();
onShippingPromoChange({ maxDiscount: parseNumber(event.target.value, 0) });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
</> </>
@@ -303,79 +440,91 @@ export function ConfigPanel({
</section> </section>
<section className="flex flex-col gap-2"> <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> <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 Add tier
</Button> </Button>
</div> </div>
{shippingTiers.length === 0 ? ( {shippingTiers.length === 0 ? (
<p className="text-xs text-muted-foreground">Add tiers to model automatic shipping discounts.</p> <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"> <div className="flex flex-col gap-2 pr-1">
{shippingTiers.map((tier, index) => ( {/* Header row */}
<div <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]">
key={`${tier.threshold}-${index}`} <div>$ Amount</div>
className="grid gap-2 rounded border px-2 py-2 text-xs sm:grid-cols-[repeat(3,minmax(0,1fr))_auto] sm:items-center" <div>Type</div>
> <div>Value</div>
<span className="text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground sm:col-span-4"> <div className="w-1.5"></div>
Tier {index + 1} </div>
</span> {shippingTiers.map((tier, index) => {
<div className={fieldClass}> const tierKey = tier.id ?? `tier-${index}`;
<Label className={labelClass}>Threshold ($)</Label> return (
<Input <div
className={compactNumberClass} key={tierKey}
type="number" 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"
step="1" >
value={tier.threshold} <div>
onChange={(event) => <Input
handleTierUpdate(index, { className={compactNumberClass}
threshold: parseNumber(event.target.value, 0), type="number"
}) step="1"
} value={tier.threshold}
/> onChange={(event) => {
onConfigInputChange();
handleTierUpdate(index, {
threshold: parseNumber(event.target.value, 0),
});
}}
onBlur={handleFieldBlur}
/>
</div>
<div>
<Select
value={tier.mode}
onValueChange={(value) => {
onConfigInputChange();
handleTierUpdate(index, { mode: value as ShippingTierConfig["mode"] });
}}
>
<SelectTrigger className={`${compactTriggerClass} w-full`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percentage">% off</SelectItem>
<SelectItem value="flat">Flat rate</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Input
className={compactNumberClass}
type="number"
step="1"
value={tier.value}
onChange={(event) => {
onConfigInputChange();
handleTierUpdate(index, { value: parseNumber(event.target.value, 0) });
}}
onBlur={handleFieldBlur}
/>
</div>
<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"
>
<X className="w-3 h-3" />
</Button>
</div>
</div> </div>
<div className={fieldClass}> );
<Label className={labelClass}>Mode</Label> })}
<Select
value={tier.mode}
onValueChange={(value) =>
handleTierUpdate(index, { mode: value as ShippingTierConfig["mode"] })
}
>
<SelectTrigger className={`${compactTriggerClass} w-full`}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="percentage">% off shipping</SelectItem>
<SelectItem value="flat">Flat rate</SelectItem>
</SelectContent>
</Select>
</div>
<div className={fieldClass}>
<Label className={labelClass}>{tier.mode === "flat" ? "Flat rate" : "Percent"}</Label>
<Input
className={compactNumberClass}
type="number"
step="1"
value={Math.round(tier.value)}
onChange={(event) =>
handleTierUpdate(index, { value: parseNumber(event.target.value, 0) })
}
/>
</div>
<div className="flex justify-end sm:col-span-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleTierRemove(index)}
>
Remove
</Button>
</div>
</div>
))}
</div> </div>
</ScrollArea> </ScrollArea>
)} )}
@@ -392,7 +541,11 @@ export function ConfigPanel({
type="number" type="number"
step="0.01" step="0.01"
value={merchantFeePercent} value={merchantFeePercent}
onChange={(event) => onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent))} onChange={(event) => {
onConfigInputChange();
onMerchantFeeChange(parseNumber(event.target.value, merchantFeePercent));
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
<div className={fieldClass}> <div className={fieldClass}>
@@ -402,7 +555,11 @@ export function ConfigPanel({
type="number" type="number"
step="0.01" step="0.01"
value={fixedCostPerOrder} value={fixedCostPerOrder}
onChange={(event) => onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder))} onChange={(event) => {
onConfigInputChange();
onFixedCostChange(parseNumber(event.target.value, fixedCostPerOrder));
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
</div> </div>
@@ -419,9 +576,11 @@ export function ConfigPanel({
type="number" type="number"
step="0.0001" step="0.0001"
value={pointsPerDollar} value={pointsPerDollar}
onChange={(event) => onChange={(event) => {
onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) }) onConfigInputChange();
} onPointsChange({ pointsPerDollar: parseNumber(event.target.value, pointsPerDollar) });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
<div className={fieldClass}> <div className={fieldClass}>
@@ -431,9 +590,11 @@ export function ConfigPanel({
type="number" type="number"
step="0.01" step="0.01"
value={redemptionRate * 100} value={redemptionRate * 100}
onChange={(event) => onChange={(event) => {
onPointsChange({ redemptionRate: parseNumber(event.target.value, redemptionRate * 100) / 100 }) onConfigInputChange();
} onPointsChange({ redemptionRate: parseNumber(event.target.value, redemptionRate * 100) / 100 });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
</div> </div>
@@ -444,9 +605,11 @@ export function ConfigPanel({
type="number" type="number"
step="0.0001" step="0.0001"
value={pointDollarValue} value={pointDollarValue}
onChange={(event) => onChange={(event) => {
onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) }) onConfigInputChange();
} onPointsChange({ pointDollarValue: parseNumber(event.target.value, pointDollarValue) });
}}
onBlur={handleFieldBlur}
/> />
</div> </div>
{recommendedPoints && ( {recommendedPoints && (
@@ -456,18 +619,20 @@ export function ConfigPanel({
Use recommended Use recommended
</Button> </Button>
)} )}
<span>
Recommended: {recommendedPoints.pointsPerDollar.toFixed(4)} pts/$ · {(recommendedPoints.redemptionRate * 100).toFixed(2)}% redeemed · ${recommendedPoints.pointDollarValue.toFixed(4)} per point
</span>
</div> </div>
)} )}
</div> </div>
</section> </section>
</div> </div>
<Button size="sm" className="sm:w-auto" onClick={onRunSimulation} disabled={isRunning}> <div className="flex flex-wrap gap-2">
{isRunning ? "Running simulation..." : "Run simulation"} <Button variant="outline" size="sm" onClick={onResetConfig} disabled={isRunning}>
</Button> Reset
</Button>
<Button size="sm" className="sm:w-auto" onClick={onRunSimulation} disabled={isRunning}>
{isRunning ? "Running simulation..." : "Run simulation"}
</Button>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -1,5 +1,5 @@
import { useMemo } from "react"; 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 { DiscountSimulationBucket } from "@/types/discount-simulator";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { import {
@@ -118,14 +118,20 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
const options = useMemo(() => ({ const options = useMemo(() => ({
responsive: true, responsive: true,
maintainAspectRatio: false,
layout: {
padding: {
left: 0,
right: 0,
top: 0,
bottom: 0,
},
},
interaction: { interaction: {
mode: 'index' as const, mode: 'index' as const,
intersect: false, intersect: false,
}, },
plugins: { plugins: {
legend: {
display: false, // Remove legend since we only have one metric
},
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (context: TooltipItem<'line'>) => { label: (context: TooltipItem<'line'>) => {
@@ -155,7 +161,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
max: 50, max: 50,
ticks: { ticks: {
stepSize: 5, stepSize: 5,
callback: (value: number | string) => `${Number(value).toFixed(0)}%`, callback: (value: number | string) => `${Number(value).toFixed(0)}`,
}, },
title: { title: {
display: true, display: true,
@@ -167,8 +173,7 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
ticks: { ticks: {
maxRotation: 90, maxRotation: 90,
minRotation: 90, minRotation: 90,
maxTicksLimit: undefined, // Show all labels autoSkip: true, // Allow skipping labels if needed
autoSkip: false, // Don't skip any labels
}, },
}, },
}, },
@@ -176,12 +181,9 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
if (isLoading && !chartData) { if (isLoading && !chartData) {
return ( return (
<Card> <Card className="pt-6">
<CardHeader>
<CardTitle>Profit Trend</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<Skeleton className="h-64 w-full" /> <Skeleton className="h-72 w-full" />
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -192,12 +194,9 @@ export function ResultsChart({ buckets, isLoading }: ResultsChartProps) {
} }
return ( return (
<Card> <Card className="pt-6">
<CardHeader>
<CardTitle>Profit Trend</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="h-72"> <div className="h-72 w-full overflow-hidden">
<Line data={chartData} options={options} /> <Line data={chartData} options={options} />
</div> </div>
</CardContent> </CardContent>

View File

@@ -7,6 +7,12 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { DiscountSimulationBucket } from "@/types/discount-simulator"; import { DiscountSimulationBucket } from "@/types/discount-simulator";
import { formatCurrency, formatNumber } from "@/utils/productUtils"; import { formatCurrency, formatNumber } from "@/utils/productUtils";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
@@ -58,24 +64,35 @@ interface ResultsTableProps {
} }
const rowLabels = [ const rowLabels = [
{ key: "orderCount", label: "Orders", format: (value: number) => formatNumber(value) }, // Most important metrics first - prominently styled
{ key: "weight", label: "Weight", format: (value: number) => `${(value * 100).toFixed(2)}%` }, { key: "totalRevenue", label: "Total Revenue", format: (value: number) => formatCurrency(value), important: true, isCalculated: true },
{ key: "orderValue", label: "Order Value", format: (value: number) => formatCurrency(value) }, { key: "totalCosts", label: "Total Cost", format: (value: number) => formatCurrency(value), important: true, isCalculated: true },
{ key: "productDiscountAmount", label: "Product Discount", format: (value: number) => formatCurrency(value) }, { key: "profit", label: "Profit $", format: (value: number) => formatCurrency(value), important: true, isCalculated: true },
{ key: "promoProductDiscount", label: "Promo Product Discount", format: (value: number) => formatCurrency(value) }, { key: "profitPercent", label: "Profit %", format: (value: number) => `${(value * 100).toFixed(2)}%`, important: true, isCalculated: true },
{ key: "customerItemCost", label: "Customer Item Cost", format: (value: number) => formatCurrency(value) },
{ key: "shippingAfterAuto", label: "Ship Charge", format: (value: number) => formatCurrency(value) }, // Order metrics - from database
{ key: "shipPromoDiscount", label: "Ship Promo Discount", format: (value: number) => formatCurrency(value) }, { key: "orderCount", label: "Orders", format: (value: number) => formatNumber(value), important: false, isCalculated: false },
{ key: "customerShipCost", label: "Customer Ship Cost", format: (value: number) => formatCurrency(value) }, { key: "weight", label: "Weight", format: (value: number) => `${(value * 100).toFixed(2)}%`, important: false, isCalculated: false },
{ key: "actualShippingCost", label: "Actual Ship Cost", format: (value: number) => formatCurrency(value) }, { key: "orderValue", label: "Order Value", format: (value: number) => formatCurrency(value), important: false, isCalculated: false },
{ key: "totalRevenue", label: "Revenue", format: (value: number) => formatCurrency(value) },
{ key: "productCogs", label: "Product COGS", format: (value: number) => formatCurrency(value) }, // Customer costs
{ key: "merchantFees", label: "Merchant Fees", format: (value: number) => formatCurrency(value) }, { key: "customerItemCost", label: "Cust Item Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
{ key: "pointsCost", label: "Points Cost", format: (value: number) => formatCurrency(value) }, { key: "customerShipCost", label: "Cust Ship Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
{ key: "fixedCosts", label: "Fixed Cost", format: (value: number) => formatCurrency(value) },
{ key: "totalCosts", label: "Total Cost", format: (value: number) => formatCurrency(value) }, // Discounts
{ key: "profit", label: "Profit", format: (value: number) => formatCurrency(value) }, { key: "productDiscountAmount", label: "Prod Discount", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
{ key: "profitPercent", label: "Profit %", format: (value: number) => `${(value * 100).toFixed(2)}%` }, { 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) => { const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
@@ -101,18 +118,21 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
} }
return ( return (
<Card> <Card className="pt-6">
<CardHeader className="px-4 py-3"> <CardContent className="px-4 pb-4 pt-0">
<CardTitle className="text-base font-semibold">Profitability by Order Value</CardTitle>
</CardHeader>
<CardContent className="px-4 pb-4 pt-0A">
<div className="overflow-x-auto max-w-full"> <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> <TableHeader>
<TableRow> <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) => ( {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)} {formatRangeUpperBound(bucket)}
</TableHead> </TableHead>
))} ))}
@@ -127,8 +147,23 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
</TableRow> </TableRow>
) : ( ) : (
rowLabels.map((row) => ( rowLabels.map((row) => (
<TableRow key={row.key}> <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 w-[140px]">{row.label}</TableCell> <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) => { {buckets.map((bucket) => {
const value = bucket[row.key as keyof DiscountSimulationBucket] as number; const value = bucket[row.key as keyof DiscountSimulationBucket] as number;
const formattedValue = row.format(value); const formattedValue = row.format(value);
@@ -137,9 +172,9 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
if (row.key === 'profitPercent') { if (row.key === 'profitPercent') {
const backgroundColor = getProfitPercentageColor(value); const backgroundColor = getProfitPercentageColor(value);
return ( 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 <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 }} style={{ backgroundColor }}
> >
{formattedValue} {formattedValue}
@@ -149,7 +184,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
} }
return ( 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} {formattedValue}
</TableCell> </TableCell>
); );

View File

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

View File

@@ -84,7 +84,10 @@ const inventoryItems = [
icon: BarChart2, icon: BarChart2,
url: "/analytics", url: "/analytics",
permission: "access:analytics" permission: "access:analytics"
}, }
];
const toolsItems = [
{ {
title: "Discount Simulator", title: "Discount Simulator",
icon: Percent, icon: Percent,
@@ -130,12 +133,12 @@ export function AppSidebar() {
}; };
// Check if user has access to any items in a section // 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; if (user?.is_admin) return true;
return items.some(item => user?.permissions?.includes(item.permission)); return items.some(item => user?.permissions?.includes(item.permission));
}; };
const renderMenuItems = (items: typeof inventoryItems) => { const renderMenuItems = (items: any[]) => {
return items.map((item) => { return items.map((item) => {
const isActive = const isActive =
location.pathname === item.url || location.pathname === item.url ||
@@ -219,6 +222,18 @@ export function AppSidebar() {
</SidebarGroup> </SidebarGroup>
)} )}
{/* Tools Section */}
{hasAccessToSection(toolsItems) && (
<SidebarGroup>
<SidebarGroupLabel>Tools</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{renderMenuItems(toolsItems)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
)}
{/* Product Setup Section */} {/* Product Setup Section */}
{hasAccessToSection(productSetupItems) && ( {hasAccessToSection(productSetupItems) && (
<SidebarGroup> <SidebarGroup>

View File

@@ -22,11 +22,12 @@ import { useToast } from "@/hooks/use-toast";
const DEFAULT_POINT_VALUE = 0.005; const DEFAULT_POINT_VALUE = 0.005;
const DEFAULT_MERCHANT_FEE = 2.9; const DEFAULT_MERCHANT_FEE = 2.9;
const DEFAULT_FIXED_COST = 1.5; const DEFAULT_FIXED_COST = 1.5;
const STORAGE_KEY = 'discount-simulator-config-v1';
const initialDateRange: DateRange = { const getDefaultDateRange = (): DateRange => ({
from: subMonths(new Date(), 6), from: subMonths(new Date(), 6),
to: new Date(), to: new Date(),
}; });
function ensureDateRange(range: DateRange): { from: Date; to: Date } { function ensureDateRange(range: DateRange): { from: Date; to: Date } {
const from = range.from ?? subMonths(new Date(), 6); const from = range.from ?? subMonths(new Date(), 6);
@@ -49,7 +50,7 @@ const defaultShippingPromo = {
export function DiscountSimulator() { export function DiscountSimulator() {
const { toast } = useToast(); const { toast } = useToast();
const [dateRange, setDateRange] = useState<DateRange>(initialDateRange); const [dateRange, setDateRange] = useState<DateRange>(() => getDefaultDateRange());
const [selectedPromoId, setSelectedPromoId] = useState<number | undefined>(undefined); const [selectedPromoId, setSelectedPromoId] = useState<number | undefined>(undefined);
const [productPromo, setProductPromo] = useState(defaultProductPromo); const [productPromo, setProductPromo] = useState(defaultProductPromo);
const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo); const [shippingPromo, setShippingPromo] = useState(defaultShippingPromo);
@@ -64,15 +65,25 @@ export function DiscountSimulator() {
const [pointsTouched, setPointsTouched] = useState(false); const [pointsTouched, setPointsTouched] = useState(false);
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined); const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
const [isSimulating, setIsSimulating] = useState(false); const [isSimulating, setIsSimulating] = useState(false);
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
const [loadedFromStorage, setLoadedFromStorage] = useState(false);
const initialRunRef = useRef(false); const initialRunRef = useRef(false);
const skipAutoRunRef = useRef(false); const skipAutoRunRef = useRef(false);
const latestPayloadKeyRef = useRef(''); const latestPayloadKeyRef = useRef('');
const pendingCountRef = useRef(0); 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[]>({ const promosQuery = useQuery<DiscountPromoOption[]>({
queryKey: ['discount-promos'], queryKey: ['discount-promos', promoDateBounds.startDate, promoDateBounds.endDate],
queryFn: async () => { 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) const rawList = Array.isArray(response?.promos)
? response.promos ? response.promos
: []; : [];
@@ -84,7 +95,14 @@ export function DiscountSimulator() {
(promo.description as string | undefined) || (promo.description as string | undefined) ||
(promo.promo_description_online as string | undefined) || (promo.promo_description_online as string | undefined) ||
(promo.promo_description_private 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 : ''); (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 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 dateEndValue = (promo.dateEnd as string | undefined) || (promo.date_end as string | undefined) || null;
@@ -94,6 +112,7 @@ export function DiscountSimulator() {
id: Number(idValue) || 0, id: Number(idValue) || 0,
code: typeof codeValue === 'string' ? codeValue : '', code: typeof codeValue === 'string' ? codeValue : '',
description: descriptionValue ?? '', description: descriptionValue ?? '',
privateDescription: privateDescriptionValue,
dateStart: dateStartValue, dateStart: dateStartValue,
dateEnd: dateEndValue, dateEnd: dateEndValue,
usageCount: Number(usageValue) || 0, usageCount: Number(usageValue) || 0,
@@ -103,6 +122,8 @@ export function DiscountSimulator() {
}, },
}); });
const promosLoading = !promosQuery.data && promosQuery.isLoading;
const createPayload = useCallback((): DiscountSimulationRequest => { const createPayload = useCallback((): DiscountSimulationRequest => {
const { from, to } = ensureDateRange(dateRange); const { from, to } = ensureDateRange(dateRange);
@@ -117,7 +138,11 @@ export function DiscountSimulator() {
}, },
productPromo, productPromo,
shippingPromo, shippingPromo,
shippingTiers, shippingTiers: shippingTiers.map((tier) => {
const { id, ...rest } = tier;
void id;
return rest;
}),
merchantFeePercent, merchantFeePercent,
fixedCostPerOrder, fixedCostPerOrder,
pointsConfig: { pointsConfig: {
@@ -195,6 +220,86 @@ export function DiscountSimulator() {
mutate(payload); mutate(payload);
}, [createPayload, mutate]); }, [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 serializedConfig = useMemo(() => {
const { from, to } = ensureDateRange(dateRange); const { from, to } = ensureDateRange(dateRange);
return JSON.stringify({ return JSON.stringify({
@@ -212,6 +317,18 @@ export function DiscountSimulator() {
}); });
}, [dateRange, selectedPromoId, productPromo, shippingPromo, shippingTiers, merchantFeePercent, fixedCostPerOrder, pointsConfig]); }, [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(() => { useEffect(() => {
if (!initialRunRef.current) { if (!initialRunRef.current) {
initialRunRef.current = true; initialRunRef.current = true;
@@ -225,6 +342,25 @@ export function DiscountSimulator() {
runSimulation(); runSimulation();
}, [serializedConfig, 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 const recommendedPoints = simulationResult?.totals
? { ? {
pointsPerDollar: simulationResult.totals.pointsPerDollar, 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 promos = promosQuery.data ?? [];
const isLoading = isSimulating && !simulationResult; const isLoading = isSimulating && !simulationResult;
@@ -265,6 +432,7 @@ export function DiscountSimulator() {
dateRange={dateRange} dateRange={dateRange}
onDateRangeChange={(range) => range && setDateRange(range)} onDateRangeChange={(range) => range && setDateRange(range)}
promos={promos} promos={promos}
promoLoading={promosLoading}
selectedPromoId={selectedPromoId} selectedPromoId={selectedPromoId}
onPromoChange={setSelectedPromoId} onPromoChange={setSelectedPromoId}
productPromo={productPromo} productPromo={productPromo}
@@ -281,27 +449,30 @@ export function DiscountSimulator() {
redemptionRate={pointsConfig.redemptionRate} redemptionRate={pointsConfig.redemptionRate}
pointDollarValue={pointsConfig.pointDollarValue} pointDollarValue={pointsConfig.pointDollarValue}
onPointsChange={handlePointsChange} onPointsChange={handlePointsChange}
onConfigInputChange={handleConfigInputChange}
onResetConfig={resetConfig}
onRunSimulation={() => runSimulation()} onRunSimulation={() => runSimulation()}
isRunning={isSimulating} isRunning={isSimulating}
recommendedPoints={recommendedPoints} recommendedPoints={recommendedPoints}
onApplyRecommendedPoints={handleApplyRecommendedPoints} onApplyRecommendedPoints={handleApplyRecommendedPoints}
result={simulationResult}
/> />
</div> </div>
{/* Right Side - Results */} {/* Right Side - Results */}
<div className="space-y-4 min-w-0 flex-1"> <div className="space-y-4 min-w-0 flex-1 overflow-hidden">
{/* Top Right - Summary (Full Width) */} {/* Top Right - Summary and Chart */}
<div className="w-full"> <div className="grid gap-4 lg:grid-cols-[160px,1fr] min-w-0">
<SummaryCard result={simulationResult} isLoading={isLoading} /> <div className="w-full min-w-0">
</div> <SummaryCard result={simulationResult} isLoading={isLoading} />
</div>
{/* Middle Right - Chart (Full Width) */} <div className="w-full min-w-0 overflow-hidden">
<div className="w-full"> <ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} /> </div>
</div> </div>
{/* Bottom Right - Table */} {/* Bottom Right - Table */}
<div className="w-full"> <div className="w-full min-w-0 overflow-hidden">
<ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} /> <ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
</div> </div>
</div> </div>

View File

@@ -179,14 +179,19 @@ export const acotService = {
); );
}, },
getDiscountPromos: async () => { getDiscountPromos: async (params = {}) => {
const cacheKey = 'discount_promos'; const { startDate, endDate } = params || {};
const cacheKey = `discount_promos_${startDate || 'none'}_${endDate || 'none'}`;
return deduplicatedRequest( return deduplicatedRequest(
cacheKey, cacheKey,
() => () =>
retryRequest( retryRequest(
async () => { async () => {
const response = await acotApi.get('/api/acot/discounts/promos', { const response = await acotApi.get('/api/acot/discounts/promos', {
params: {
...(startDate ? { startDate } : {}),
...(endDate ? { endDate } : {}),
},
timeout: 60000, timeout: 60000,
}); });
return response.data; return response.data;

View File

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

View File

@@ -2,6 +2,7 @@ export interface DiscountPromoOption {
id: number; id: number;
code: string; code: string;
description: string; description: string;
privateDescription: string;
dateStart: string | null; dateStart: string | null;
dateEnd: string | null; dateEnd: string | null;
usageCount: number; usageCount: number;
@@ -15,6 +16,7 @@ export interface ShippingTierConfig {
threshold: number; threshold: number;
mode: ShippingTierMode; mode: ShippingTierMode;
value: number; value: number;
id?: string;
} }
export interface DiscountSimulationBucket { export interface DiscountSimulationBucket {