Regroup sidebar, discount sim layout updates and fixes
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo } from "react";
|
||||
import { endOfDay, startOfDay } from "date-fns";
|
||||
import { DateRange } from "react-day-picker";
|
||||
import { DateRangePicker } from "@/components/ui/date-range-picker";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
@@ -7,13 +8,18 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig } from "@/types/discount-simulator";
|
||||
import { DiscountPromoOption, DiscountPromoType, ShippingPromoType, ShippingTierConfig, DiscountSimulationResponse } from "@/types/discount-simulator";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatCurrency, formatNumber } from "@/utils/productUtils";
|
||||
import { PlusIcon, X } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
interface ConfigPanelProps {
|
||||
dateRange: DateRange;
|
||||
onDateRangeChange: (range: DateRange | undefined) => void;
|
||||
promos: DiscountPromoOption[];
|
||||
promoLoading?: boolean;
|
||||
selectedPromoId?: number;
|
||||
onPromoChange: (promoId: number | undefined) => void;
|
||||
productPromo: {
|
||||
@@ -43,6 +49,8 @@ interface ConfigPanelProps {
|
||||
redemptionRate?: number;
|
||||
pointDollarValue?: number;
|
||||
}) => void;
|
||||
onConfigInputChange: () => void;
|
||||
onResetConfig: () => void;
|
||||
onRunSimulation: () => void;
|
||||
isRunning: boolean;
|
||||
recommendedPoints?: {
|
||||
@@ -51,6 +59,7 @@ interface ConfigPanelProps {
|
||||
pointDollarValue: number;
|
||||
};
|
||||
onApplyRecommendedPoints?: () => void;
|
||||
result?: DiscountSimulationResponse;
|
||||
}
|
||||
|
||||
function parseNumber(value: string, fallback = 0) {
|
||||
@@ -58,6 +67,36 @@ function parseNumber(value: string, fallback = 0) {
|
||||
return Number.isFinite(parsed) ? parsed : fallback;
|
||||
}
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
if (!Number.isFinite(value)) return 'N/A';
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
const generateTierId = () => `tier-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
|
||||
const parseDateToTimestamp = (value?: string | null): number | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
const timestamp = new Date(value).getTime();
|
||||
return Number.isFinite(timestamp) ? timestamp : undefined;
|
||||
};
|
||||
|
||||
const promoOverlapsRange = (
|
||||
promo: DiscountPromoOption,
|
||||
rangeStart?: number,
|
||||
rangeEnd?: number
|
||||
): boolean => {
|
||||
if (rangeStart == null || rangeEnd == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const promoStart = parseDateToTimestamp(promo.dateStart) ?? Number.NEGATIVE_INFINITY;
|
||||
const promoEnd = parseDateToTimestamp(promo.dateEnd) ?? Number.POSITIVE_INFINITY;
|
||||
|
||||
return promoStart <= rangeEnd && promoEnd >= rangeStart;
|
||||
};
|
||||
|
||||
export function ConfigPanel({
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
@@ -78,24 +117,69 @@ export function ConfigPanel({
|
||||
redemptionRate,
|
||||
pointDollarValue,
|
||||
onPointsChange,
|
||||
onConfigInputChange,
|
||||
onResetConfig,
|
||||
promoLoading = false,
|
||||
onRunSimulation,
|
||||
isRunning,
|
||||
recommendedPoints,
|
||||
onApplyRecommendedPoints
|
||||
onApplyRecommendedPoints,
|
||||
result
|
||||
}: ConfigPanelProps) {
|
||||
const promoOptions = useMemo(() => {
|
||||
return promos.map((promo) => ({
|
||||
value: promo.id.toString(),
|
||||
label: promo.description || promo.code,
|
||||
description: promo.description,
|
||||
}));
|
||||
}, [promos]);
|
||||
if (!Array.isArray(promos) || promos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rangeStartDate = dateRange?.from ? startOfDay(dateRange.from) : undefined;
|
||||
const rangeEndSource = dateRange?.to ?? dateRange?.from;
|
||||
const rangeEndDate = rangeEndSource ? endOfDay(rangeEndSource) : undefined;
|
||||
const rangeStartTimestamp = rangeStartDate?.getTime();
|
||||
const rangeEndTimestamp = rangeEndDate?.getTime();
|
||||
|
||||
const filteredPromos = promos.filter((promo) =>
|
||||
promoOverlapsRange(promo, rangeStartTimestamp, rangeEndTimestamp)
|
||||
);
|
||||
|
||||
return filteredPromos
|
||||
.sort((a, b) => Number(b.id) - Number(a.id))
|
||||
.map((promo) => {
|
||||
const privateDescription = (promo.privateDescription ?? '').trim();
|
||||
return {
|
||||
value: promo.id.toString(),
|
||||
label: promo.code || `Promo ${promo.id}`,
|
||||
description: privateDescription,
|
||||
};
|
||||
});
|
||||
}, [promos, dateRange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shippingTiers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tiersMissingIds = shippingTiers.some((tier) => !tier.id);
|
||||
if (!tiersMissingIds) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedTiers = shippingTiers.map((tier) =>
|
||||
tier.id ? tier : { ...tier, id: generateTierId() }
|
||||
);
|
||||
onShippingTiersChange(normalizedTiers);
|
||||
}, [shippingTiers, onShippingTiersChange]);
|
||||
|
||||
const handleTierUpdate = (index: number, update: Partial<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,39 +231,48 @@ 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}>
|
||||
<Label className={labelClass}>Promo code</Label>
|
||||
<Select
|
||||
value={selectedPromoId !== undefined ? selectedPromoId.toString() : undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__all__') {
|
||||
onPromoChange(undefined);
|
||||
<Select
|
||||
value={selectedPromoId !== undefined ? selectedPromoId.toString() : undefined}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__all__') {
|
||||
onPromoChange(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||
<SelectValue placeholder="All promos" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-56">
|
||||
<SelectItem value="__all__">All promos</SelectItem>
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
{promoOptions.map((promo) => (
|
||||
<SelectItem key={promo.value} value={promo.value}>
|
||||
<div className="flex flex-col text-xs">
|
||||
@@ -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) => (
|
||||
<div
|
||||
key={`${tier.threshold}-${index}`}
|
||||
className="grid gap-2 rounded border px-2 py-2 text-xs sm:grid-cols-[repeat(3,minmax(0,1fr))_auto] sm:items-center"
|
||||
>
|
||||
<span className="text-[0.65rem] font-medium uppercase tracking-wide text-muted-foreground sm:col-span-4">
|
||||
Tier {index + 1}
|
||||
</span>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Threshold ($)</Label>
|
||||
<Input
|
||||
className={compactNumberClass}
|
||||
type="number"
|
||||
step="1"
|
||||
value={tier.threshold}
|
||||
onChange={(event) =>
|
||||
handleTierUpdate(index, {
|
||||
threshold: parseNumber(event.target.value, 0),
|
||||
})
|
||||
}
|
||||
/>
|
||||
{/* 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={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"
|
||||
>
|
||||
<div>
|
||||
<Input
|
||||
className={compactNumberClass}
|
||||
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 className={fieldClass}>
|
||||
<Label className={labelClass}>Mode</Label>
|
||||
<Select
|
||||
value={tier.mode}
|
||||
onValueChange={(value) =>
|
||||
handleTierUpdate(index, { mode: value as ShippingTierConfig["mode"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="percentage">% off shipping</SelectItem>
|
||||
<SelectItem value="flat">Flat rate</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>{tier.mode === "flat" ? "Flat rate" : "Percent"}</Label>
|
||||
<Input
|
||||
className={compactNumberClass}
|
||||
type="number"
|
||||
step="1"
|
||||
value={Math.round(tier.value)}
|
||||
onChange={(event) =>
|
||||
handleTierUpdate(index, { value: parseNumber(event.target.value, 0) })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end sm:col-span-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTierRemove(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
@@ -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>
|
||||
|
||||
<Button size="sm" className="sm:w-auto" onClick={onRunSimulation} disabled={isRunning}>
|
||||
{isRunning ? "Running simulation..." : "Run simulation"}
|
||||
</Button>
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
<Skeleton className="h-8 w-16" />
|
||||
<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>
|
||||
<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">
|
||||
{totals ? (
|
||||
<span
|
||||
className="inline-block px-3 py-1 rounded text-white font-medium text-lg"
|
||||
style={{ backgroundColor: profitPercentageColor }}
|
||||
>
|
||||
{weightedProfitPercent}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant={weightedBadgeVariant} className="text-lg py-1 px-3">
|
||||
{weightedProfitPercent}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div 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-4 py-2 rounded text-white font-semibold text-2xl"
|
||||
style={{ backgroundColor: profitPercentageColor }}
|
||||
>
|
||||
{weightedProfitPercent}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant={weightedBadgeVariant} className="text-2xl py-2 px-4 font-semibold">
|
||||
{weightedProfitPercent}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator orientation="horizontal" />
|
||||
<div className="text-center">
|
||||
<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>
|
||||
|
||||
{/* Right side - Secondary metrics */}
|
||||
{totals && (
|
||||
<div className="grid grid-cols-5 gap-6 text-sm">
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-1">Orders</p>
|
||||
<p className="font-medium">{formatNumber(totals.orders)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-1">Avg Discount</p>
|
||||
<p className="font-medium">{formatPercent(totals.productDiscountRate)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-1">Points/$</p>
|
||||
<p className="font-medium">{totals.pointsPerDollar.toFixed(4)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-1">Redeemed</p>
|
||||
<p className="font-medium">{formatPercent(totals.redemptionRate)}</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-muted-foreground mb-1">Point Value</p>
|
||||
<p className="font-medium">{formatCurrency(totals.pointDollarValue, 4)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
<SummaryCard result={simulationResult} isLoading={isLoading} />
|
||||
</div>
|
||||
|
||||
{/* Middle Right - Chart (Full Width) */}
|
||||
<div className="w-full">
|
||||
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
2
inventory/src/types/dashboard-shims.d.ts
vendored
2
inventory/src/types/dashboard-shims.d.ts
vendored
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user