Add baseline comparison for discount simulator
This commit is contained in:
@@ -1,9 +1,12 @@
|
||||
import { differenceInDays } from "date-fns";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Bookmark, X } from "lucide-react";
|
||||
|
||||
// Utility function to interpolate between two colors
|
||||
const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => {
|
||||
@@ -49,6 +52,37 @@ const getProfitPercentageColor = (percentage: number): string => {
|
||||
interface SummaryCardProps {
|
||||
result?: DiscountSimulationResponse;
|
||||
isLoading: boolean;
|
||||
baselineResult?: DiscountSimulationResponse;
|
||||
onSaveAsBaseline?: () => void;
|
||||
onClearBaseline?: () => void;
|
||||
}
|
||||
|
||||
function calculateAnnualizedProfitDiff(
|
||||
current: DiscountSimulationResponse,
|
||||
baseline: DiscountSimulationResponse
|
||||
): number | null {
|
||||
const currentTotals = current.totals;
|
||||
const baselineTotals = baseline.totals;
|
||||
|
||||
if (!currentTotals || !baselineTotals) return null;
|
||||
|
||||
// Calculate days in the current simulation period
|
||||
const startDate = new Date(current.dateRange.start);
|
||||
const endDate = new Date(current.dateRange.end);
|
||||
const daysInPeriod = differenceInDays(endDate, startDate) + 1; // +1 to include both start and end days
|
||||
|
||||
if (daysInPeriod <= 0) return null;
|
||||
|
||||
// Profit difference per order
|
||||
const profitDiffPerOrder = currentTotals.weightedProfitAmount - baselineTotals.weightedProfitAmount;
|
||||
|
||||
// Total profit difference for the period
|
||||
const totalProfitDiff = profitDiffPerOrder * currentTotals.orders;
|
||||
|
||||
// Annualize: scale to 365 days
|
||||
const annualizedDiff = (totalProfitDiff / daysInPeriod) * 365;
|
||||
|
||||
return annualizedDiff;
|
||||
}
|
||||
|
||||
const formatPercent = (value: number) => {
|
||||
@@ -56,16 +90,23 @@ const formatPercent = (value: number) => {
|
||||
return `${(value * 100).toFixed(2)}%`;
|
||||
};
|
||||
|
||||
export function SummaryCard({ result, isLoading }: SummaryCardProps) {
|
||||
export function SummaryCard({
|
||||
result,
|
||||
isLoading,
|
||||
baselineResult,
|
||||
onSaveAsBaseline,
|
||||
onClearBaseline
|
||||
}: SummaryCardProps) {
|
||||
if (isLoading && !result) {
|
||||
return (
|
||||
<Card className="pt-6">
|
||||
<CardContent>
|
||||
<div className="h-72 flex flex-col items-center justify-center gap-8">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex 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" />
|
||||
<Skeleton className="h-8 w-20 mx-auto" />
|
||||
</div>
|
||||
<Separator orientation="vertical" className="h-12" />
|
||||
<div className="text-center space-y-2">
|
||||
<Skeleton className="h-4 w-32 mx-auto" />
|
||||
<Skeleton className="h-8 w-24 mx-auto" />
|
||||
@@ -89,32 +130,84 @@ export function SummaryCard({ result, isLoading }: SummaryCardProps) {
|
||||
: "destructive"
|
||||
: "secondary";
|
||||
|
||||
// Calculate annualized profit difference if baseline exists
|
||||
const annualizedDiff = result && baselineResult
|
||||
? calculateAnnualizedProfitDiff(result, baselineResult)
|
||||
: null;
|
||||
const hasBaseline = !!baselineResult;
|
||||
const isPositiveDiff = annualizedDiff !== null && annualizedDiff >= 0;
|
||||
|
||||
return (
|
||||
<Card className="pt-6">
|
||||
<CardContent>
|
||||
<div className="h-72 flex flex-col items-center justify-center gap-5">
|
||||
<Card>
|
||||
<CardContent className="py-4">
|
||||
<div className="flex items-center justify-center gap-6">
|
||||
{/* Weighted Average Profit */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-3">Weighted Average Profit</p>
|
||||
<p className="text-sm text-muted-foreground mb-2">Weighted Average Profit</p>
|
||||
{totals ? (
|
||||
<span
|
||||
className="inline-block px-4 py-2 rounded text-white font-semibold text-2xl"
|
||||
className="inline-block px-3 py-1.5 rounded text-white font-semibold text-lg"
|
||||
style={{ backgroundColor: profitPercentageColor }}
|
||||
>
|
||||
{weightedProfitPercent}
|
||||
</span>
|
||||
) : (
|
||||
<Badge variant={weightedBadgeVariant} className="text-2xl py-2 px-4 font-semibold">
|
||||
<Badge variant={weightedBadgeVariant} className="text-lg py-1.5 px-3 font-semibold">
|
||||
{weightedProfitPercent}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator orientation="horizontal" />
|
||||
|
||||
<Separator orientation="vertical" className="h-12" />
|
||||
|
||||
{/* Weighted Profit Per Order */}
|
||||
<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">
|
||||
<p className="text-sm text-muted-foreground mb-2">Weighted Profit Per Order</p>
|
||||
<Badge variant="secondary" className="text-lg py-1.5 px-3 font-semibold">
|
||||
{weightedProfitAmount}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Baseline comparison section */}
|
||||
{hasBaseline && annualizedDiff !== null && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-12" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">Annual Change vs Baseline</p>
|
||||
<span
|
||||
className={`inline-block px-3 py-1.5 rounded bg-secondary font-semibold text-lg ${
|
||||
isPositiveDiff ? 'text-green-600' : 'text-red-600'
|
||||
}`}
|
||||
>
|
||||
{isPositiveDiff ? '+' : ''}{formatCurrency(annualizedDiff)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
onClick={onClearBaseline}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Save as baseline button */}
|
||||
{result && !hasBaseline && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-12" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onSaveAsBaseline}
|
||||
>
|
||||
<Bookmark className="h-3 w-3 mr-1" />
|
||||
Set as baseline
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -62,6 +62,7 @@ export function DiscountSimulator() {
|
||||
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
||||
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
||||
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||
const [baselineResult, setBaselineResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
|
||||
const [loadedFromStorage, setLoadedFromStorage] = useState(false);
|
||||
@@ -402,6 +403,16 @@ export function DiscountSimulator() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAsBaseline = useCallback(() => {
|
||||
if (simulationResult) {
|
||||
setBaselineResult(simulationResult);
|
||||
}
|
||||
}, [simulationResult]);
|
||||
|
||||
const handleClearBaseline = useCallback(() => {
|
||||
setBaselineResult(undefined);
|
||||
}, []);
|
||||
|
||||
const resetConfig = useCallback(() => {
|
||||
skipAutoRunRef.current = true;
|
||||
const defaultRange = getDefaultDateRange();
|
||||
@@ -478,17 +489,21 @@ export function DiscountSimulator() {
|
||||
|
||||
{/* Right Side - Results */}
|
||||
<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>
|
||||
{/* Top - Summary (horizontal) */}
|
||||
<SummaryCard
|
||||
result={simulationResult}
|
||||
isLoading={isLoading}
|
||||
baselineResult={baselineResult}
|
||||
onSaveAsBaseline={handleSaveAsBaseline}
|
||||
onClearBaseline={handleClearBaseline}
|
||||
/>
|
||||
|
||||
{/* Chart */}
|
||||
<div className="w-full min-w-0 overflow-hidden">
|
||||
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
||||
</div>
|
||||
|
||||
{/* Bottom Right - Table */}
|
||||
{/* Table */}
|
||||
<div className="w-full min-w-0 overflow-hidden">
|
||||
<ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user