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 { Card, CardContent } from "@/components/ui/card";
|
||||||
import { formatCurrency } 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 { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Bookmark, X } from "lucide-react";
|
||||||
|
|
||||||
// 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] => {
|
||||||
@@ -49,6 +52,37 @@ const getProfitPercentageColor = (percentage: number): string => {
|
|||||||
interface SummaryCardProps {
|
interface SummaryCardProps {
|
||||||
result?: DiscountSimulationResponse;
|
result?: DiscountSimulationResponse;
|
||||||
isLoading: boolean;
|
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) => {
|
const formatPercent = (value: number) => {
|
||||||
@@ -56,16 +90,23 @@ const formatPercent = (value: number) => {
|
|||||||
return `${(value * 100).toFixed(2)}%`;
|
return `${(value * 100).toFixed(2)}%`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SummaryCard({ result, isLoading }: SummaryCardProps) {
|
export function SummaryCard({
|
||||||
|
result,
|
||||||
|
isLoading,
|
||||||
|
baselineResult,
|
||||||
|
onSaveAsBaseline,
|
||||||
|
onClearBaseline
|
||||||
|
}: SummaryCardProps) {
|
||||||
if (isLoading && !result) {
|
if (isLoading && !result) {
|
||||||
return (
|
return (
|
||||||
<Card className="pt-6">
|
<Card>
|
||||||
<CardContent>
|
<CardContent className="py-4">
|
||||||
<div className="h-72 flex flex-col items-center justify-center gap-8">
|
<div className="flex items-center justify-center gap-8">
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Skeleton className="h-4 w-24 mx-auto" />
|
<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>
|
</div>
|
||||||
|
<Separator orientation="vertical" className="h-12" />
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<Skeleton className="h-4 w-32 mx-auto" />
|
<Skeleton className="h-4 w-32 mx-auto" />
|
||||||
<Skeleton className="h-8 w-24 mx-auto" />
|
<Skeleton className="h-8 w-24 mx-auto" />
|
||||||
@@ -89,32 +130,84 @@ export function SummaryCard({ result, isLoading }: SummaryCardProps) {
|
|||||||
: "destructive"
|
: "destructive"
|
||||||
: "secondary";
|
: "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 (
|
return (
|
||||||
<Card className="pt-6">
|
<Card>
|
||||||
<CardContent>
|
<CardContent className="py-4">
|
||||||
<div className="h-72 flex flex-col items-center justify-center gap-5">
|
<div className="flex items-center justify-center gap-6">
|
||||||
|
{/* Weighted Average Profit */}
|
||||||
<div className="text-center">
|
<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 ? (
|
{totals ? (
|
||||||
<span
|
<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 }}
|
style={{ backgroundColor: profitPercentageColor }}
|
||||||
>
|
>
|
||||||
{weightedProfitPercent}
|
{weightedProfitPercent}
|
||||||
</span>
|
</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}
|
{weightedProfitPercent}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="horizontal" />
|
|
||||||
|
<Separator orientation="vertical" className="h-12" />
|
||||||
|
|
||||||
|
{/* Weighted Profit Per Order */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-sm text-muted-foreground mb-3">Weighted Profit Per Order</p>
|
<p className="text-sm text-muted-foreground mb-2">Weighted Profit Per Order</p>
|
||||||
<Badge variant="secondary" className="text-2xl py-2 px-4 font-semibold">
|
<Badge variant="secondary" className="text-lg py-1.5 px-3 font-semibold">
|
||||||
{weightedProfitAmount}
|
{weightedProfitAmount}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function DiscountSimulator() {
|
|||||||
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
const [pointDollarValue, setPointDollarValue] = useState(DEFAULT_POINT_VALUE);
|
||||||
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
const [pointDollarTouched, setPointDollarTouched] = useState(false);
|
||||||
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
const [simulationResult, setSimulationResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||||
|
const [baselineResult, setBaselineResult] = useState<DiscountSimulationResponse | undefined>(undefined);
|
||||||
const [isSimulating, setIsSimulating] = useState(false);
|
const [isSimulating, setIsSimulating] = useState(false);
|
||||||
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
|
const [hasLoadedConfig, setHasLoadedConfig] = useState(false);
|
||||||
const [loadedFromStorage, setLoadedFromStorage] = 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(() => {
|
const resetConfig = useCallback(() => {
|
||||||
skipAutoRunRef.current = true;
|
skipAutoRunRef.current = true;
|
||||||
const defaultRange = getDefaultDateRange();
|
const defaultRange = getDefaultDateRange();
|
||||||
@@ -478,17 +489,21 @@ export function DiscountSimulator() {
|
|||||||
|
|
||||||
{/* Right Side - Results */}
|
{/* Right Side - Results */}
|
||||||
<div className="space-y-4 min-w-0 flex-1 overflow-hidden">
|
<div className="space-y-4 min-w-0 flex-1 overflow-hidden">
|
||||||
{/* Top Right - Summary and Chart */}
|
{/* Top - Summary (horizontal) */}
|
||||||
<div className="grid gap-4 lg:grid-cols-[160px,1fr] min-w-0">
|
<SummaryCard
|
||||||
<div className="w-full min-w-0">
|
result={simulationResult}
|
||||||
<SummaryCard result={simulationResult} isLoading={isLoading} />
|
isLoading={isLoading}
|
||||||
</div>
|
baselineResult={baselineResult}
|
||||||
|
onSaveAsBaseline={handleSaveAsBaseline}
|
||||||
|
onClearBaseline={handleClearBaseline}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Chart */}
|
||||||
<div className="w-full min-w-0 overflow-hidden">
|
<div className="w-full min-w-0 overflow-hidden">
|
||||||
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
<ResultsChart buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Bottom Right - Table */}
|
{/* Table */}
|
||||||
<div className="w-full min-w-0 overflow-hidden">
|
<div className="w-full min-w-0 overflow-hidden">
|
||||||
<ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
<ResultsTable buckets={simulationResult?.buckets ?? []} isLoading={isSimulating} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user