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;
|
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)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
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>;
|
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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user