Restyle config panel and results table
This commit is contained in:
@@ -9,8 +9,6 @@ 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, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { formatNumber } from "@/utils/productUtils";
|
||||
import { PlusIcon, X } from "lucide-react";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
@@ -118,8 +116,6 @@ export function ConfigPanel({
|
||||
promoLoading = false,
|
||||
onRunSimulation,
|
||||
isRunning,
|
||||
recommendedPointDollarValue,
|
||||
onApplyRecommendedPointDollarValue,
|
||||
result
|
||||
}: ConfigPanelProps) {
|
||||
const promoOptions = useMemo(() => {
|
||||
@@ -171,21 +167,22 @@ export function ConfigPanel({
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tierId = current.id ?? generateTierId();
|
||||
tiers[index] = {
|
||||
const mergedTier = {
|
||||
...current,
|
||||
...update,
|
||||
id: tierId,
|
||||
};
|
||||
const sorted = tiers
|
||||
.filter((tier) => tier != null)
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
threshold: Number.isFinite(tier.threshold) ? tier.threshold : 0,
|
||||
value: Number.isFinite(tier.value) ? tier.value : 0,
|
||||
}))
|
||||
.sort((a, b) => a.threshold - b.threshold);
|
||||
onShippingTiersChange(sorted);
|
||||
|
||||
const normalizedTier: ShippingTierConfig = {
|
||||
...mergedTier,
|
||||
threshold: Number.isFinite(mergedTier.threshold) ? mergedTier.threshold ?? 0 : 0,
|
||||
value: Number.isFinite(mergedTier.value) ? mergedTier.value ?? 0 : 0,
|
||||
};
|
||||
|
||||
tiers[index] = normalizedTier;
|
||||
onShippingTiersChange(tiers);
|
||||
};
|
||||
|
||||
const handleTierRemove = (index: number) => {
|
||||
@@ -209,118 +206,152 @@ export function ConfigPanel({
|
||||
onShippingTiersChange(tiers);
|
||||
};
|
||||
|
||||
const recommendedDiffers = typeof recommendedPointDollarValue === 'number'
|
||||
? recommendedPointDollarValue !== pointDollarValue
|
||||
: false;
|
||||
|
||||
const fieldClass = "flex flex-col gap-1.5";
|
||||
const labelClass = "text-[0.65rem] uppercase tracking-wide text-muted-foreground";
|
||||
const sectionTitleClass = "text-xs font-semibold uppercase tracking-wide text-muted-foreground";
|
||||
const sectionClass = "flex flex-col gap-3";
|
||||
const fieldRowClass = "flex flex-col gap-3";
|
||||
const fieldRowHorizontalClass = "flex gap-3";
|
||||
const compactTriggerClass = "h-8 px-2 text-xs";
|
||||
const compactNumberClass = "h-8 px-2 text-sm";
|
||||
const compactWideNumberClass = "h-8 px-2 text-sm";
|
||||
const showProductAdjustments = productPromo.type !== "none";
|
||||
const showShippingAdjustments = shippingPromo.type !== "none";
|
||||
const promoSelectValue = selectedPromoId != null ? selectedPromoId.toString() : "__all__";
|
||||
|
||||
const handleFieldBlur = useCallback(() => {
|
||||
setTimeout(onRunSimulation, 0);
|
||||
}, [onRunSimulation]);
|
||||
|
||||
const sortShippingTiers = useCallback(() => {
|
||||
if (shippingTiers.length < 2) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalIds = shippingTiers.map((tier) => tier.id);
|
||||
const sortedTiers = [...shippingTiers]
|
||||
.map((tier) => ({
|
||||
...tier,
|
||||
threshold: Number.isFinite(tier.threshold) ? tier.threshold : 0,
|
||||
value: Number.isFinite(tier.value) ? tier.value : 0,
|
||||
}))
|
||||
.sort((a, b) => a.threshold - b.threshold);
|
||||
|
||||
const orderChanged = sortedTiers.some((tier, index) => tier.id !== originalIds[index]);
|
||||
if (orderChanged) {
|
||||
onShippingTiersChange(sortedTiers);
|
||||
}
|
||||
}, [shippingTiers, onShippingTiersChange]);
|
||||
|
||||
const handleTierBlur = useCallback(() => {
|
||||
sortShippingTiers();
|
||||
handleFieldBlur();
|
||||
}, [sortShippingTiers, handleFieldBlur]);
|
||||
|
||||
|
||||
const sectionTitleClass = "text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-muted-foreground";
|
||||
const sectionBaseClass = "flex flex-col rounded-md border border-border/60 bg-muted/30 px-3 py-2.5";
|
||||
const sectionClass = `${sectionBaseClass} gap-3`;
|
||||
const compactSectionClass = `${sectionBaseClass} gap-2`;
|
||||
const sectionHeaderClass = "flex items-center justify-between";
|
||||
const fieldClass = "flex flex-col gap-1";
|
||||
const labelClass = "text-[0.65rem] uppercase tracking-wide text-muted-foreground";
|
||||
const fieldRowClass = "flex flex-col gap-2";
|
||||
const fieldRowHorizontalClass = "flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-3";
|
||||
const compactTriggerClass = "h-8 px-2 text-xs";
|
||||
const compactNumberClass = "h-8 px-2 text-sm";
|
||||
const compactWideNumberClass = "h-8 px-2 text-sm";
|
||||
const metricPillClass = "flex items-center gap-1 rounded border border-border/60 bg-background px-2 py-1 text-[0.68rem] font-medium text-foreground";
|
||||
const showProductAdjustments = productPromo.type !== "none";
|
||||
const showShippingAdjustments = shippingPromo.type !== "none";
|
||||
const promoSelectValue = selectedPromoId != null ? selectedPromoId.toString() : "__all__";
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardContent className="flex flex-col gap-4 px-4 py-4">
|
||||
<div className="space-y-6">
|
||||
<CardContent className="flex flex-col gap-3 px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<section className={sectionClass}>
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Promo code</Label>
|
||||
<Select
|
||||
value={promoSelectValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__all__') {
|
||||
onPromoChange(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
|
||||
}}
|
||||
>
|
||||
<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">
|
||||
<span>{promo.label}</span>
|
||||
{promo.description && (
|
||||
<span className="text-muted-foreground">{promo.description}</span>
|
||||
)}
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Date range</Label>
|
||||
<DateRangePicker
|
||||
value={dateRange}
|
||||
onChange={(range) => onDateRangeChange(range)}
|
||||
className=""
|
||||
/>
|
||||
</div>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Promo code</Label>
|
||||
<Select
|
||||
value={promoSelectValue}
|
||||
onValueChange={(value) => {
|
||||
if (value === '__all__') {
|
||||
onPromoChange(undefined);
|
||||
return;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||
{promoLoading ? (
|
||||
<div className="flex w-full items-center">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<SelectValue placeholder="All promos">
|
||||
{promoSelectValue === "__all__"
|
||||
? "All promos"
|
||||
: promoOptions.find(p => p.value === promoSelectValue)?.label
|
||||
}
|
||||
</SelectValue>
|
||||
)}
|
||||
</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">
|
||||
<span>{promo.label}</span>
|
||||
{promo.description && (
|
||||
<span className="text-muted-foreground">{promo.description}</span>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<div className={`${metricPillClass} whitespace-nowrap`}>
|
||||
<span className="text-xs font-semibold">{formatNumber(result.totals.orders)}</span>
|
||||
<span className="text-[0.6rem] uppercase text-muted-foreground">orders</span>
|
||||
</div>
|
||||
<div className={`${metricPillClass} whitespace-nowrap`}>
|
||||
<span className="text-xs font-semibold">{formatPercent(result.totals.productDiscountRate)}</span>
|
||||
<span className="text-[0.6rem] uppercase text-muted-foreground">avg discount</span>
|
||||
</div>
|
||||
{result.totals.overallCogsPercentage != null && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{formatPercent(result.totals.overallCogsPercentage)} avg COGS
|
||||
</Badge>
|
||||
<div className={`${metricPillClass} whitespace-nowrap`}>
|
||||
<span className="text-xs font-semibold">{formatPercent(result.totals.overallCogsPercentage)}</span>
|
||||
<span className="text-[0.6rem] uppercase text-muted-foreground">avg COGS</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
<Separator />
|
||||
<section className={sectionClass}>
|
||||
<span className={sectionTitleClass}>Product promo</span>
|
||||
<div className={sectionHeaderClass}>
|
||||
<span className={sectionTitleClass}>Product promo</span>
|
||||
</div>
|
||||
<div className={fieldRowClass}>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Promo type</Label>
|
||||
<Select
|
||||
value={productPromo.type}
|
||||
onValueChange={(value) => onProductPromoChange({ type: value as DiscountPromoType })}
|
||||
>
|
||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No additional promo</SelectItem>
|
||||
<SelectItem value="percentage_subtotal">% off subtotal</SelectItem>
|
||||
<SelectItem value="percentage_regular">% off regular price</SelectItem>
|
||||
<SelectItem value="fixed_amount">Fixed dollar discount</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Label className={labelClass}>Promo type</Label>
|
||||
<Select
|
||||
value={productPromo.type}
|
||||
onValueChange={(value) => onProductPromoChange({ type: value as DiscountPromoType })}
|
||||
>
|
||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No additional promo</SelectItem>
|
||||
<SelectItem value="percentage_subtotal">% off subtotal</SelectItem>
|
||||
<SelectItem value="percentage_regular">% off regular price</SelectItem>
|
||||
<SelectItem value="fixed_amount">Fixed dollar discount</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{showProductAdjustments && (
|
||||
<div className={fieldRowHorizontalClass}>
|
||||
<div className={fieldClass}>
|
||||
@@ -359,24 +390,26 @@ export function ConfigPanel({
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<span className={sectionTitleClass}>Shipping promo</span>
|
||||
<div className={sectionHeaderClass}>
|
||||
<span className={sectionTitleClass}>Shipping promo</span>
|
||||
</div>
|
||||
<div className={fieldRowClass}>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Promo type</Label>
|
||||
<Select
|
||||
value={shippingPromo.type}
|
||||
onValueChange={(value) => onShippingPromoChange({ type: value as ShippingPromoType })}
|
||||
>
|
||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No shipping promo</SelectItem>
|
||||
<SelectItem value="percentage">% off shipping charge</SelectItem>
|
||||
<SelectItem value="fixed">Fixed dollar discount</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Label className={labelClass}>Promo type</Label>
|
||||
<Select
|
||||
value={shippingPromo.type}
|
||||
onValueChange={(value) => onShippingPromoChange({ type: value as ShippingPromoType })}
|
||||
>
|
||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No shipping promo</SelectItem>
|
||||
<SelectItem value="percentage">% off shipping charge</SelectItem>
|
||||
<SelectItem value="fixed">Fixed dollar discount</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{showShippingAdjustments && (
|
||||
<>
|
||||
<div className={fieldRowHorizontalClass}>
|
||||
@@ -430,100 +463,100 @@ export function ConfigPanel({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={sectionTitleClass}>Shipping tiers</span>
|
||||
<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="">
|
||||
<div className="flex flex-col gap-2 pr-1">
|
||||
{/* 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>
|
||||
<section className={compactSectionClass}>
|
||||
<div className={sectionHeaderClass}>
|
||||
<span className={sectionTitleClass}>Shipping tiers</span>
|
||||
<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>
|
||||
<div className="flex flex-col gap-2 pr-1 -mx-2">
|
||||
<div className="grid gap-2 px-2 py-1 text-[0.65rem] font-medium uppercase tracking-[0.18em] 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" aria-hidden="true" />
|
||||
</div>
|
||||
{shippingTiers.map((tier, index) => {
|
||||
const tierKey = tier.id ?? `tier-${index}`;
|
||||
return (
|
||||
<div
|
||||
key={tierKey}
|
||||
className="relative grid gap-2 rounded 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={handleTierBlur}
|
||||
/>
|
||||
</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={handleTierBlur}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-1.5" aria-hidden="true" />
|
||||
<div className="absolute -right-0.5 top-1/2 -translate-y-1/2 flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTierRemove(index)}
|
||||
className="p-1"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</section>
|
||||
<Separator />
|
||||
</ScrollArea>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<span className={sectionTitleClass}>Order costs</span>
|
||||
<div className={sectionHeaderClass}>
|
||||
<span className={sectionTitleClass}>Order costs</span>
|
||||
</div>
|
||||
<div className={fieldRowClass}>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>COGS calculation</Label>
|
||||
@@ -559,7 +592,7 @@ export function ConfigPanel({
|
||||
/>
|
||||
</div>
|
||||
<div className={fieldClass}>
|
||||
<Label className={labelClass}>Fixed cost/order ($)</Label>
|
||||
<Label className={labelClass}>Fixed costs ($)</Label>
|
||||
<Input
|
||||
className={compactNumberClass}
|
||||
type="number"
|
||||
@@ -577,7 +610,9 @@ export function ConfigPanel({
|
||||
</section>
|
||||
|
||||
<section className={sectionClass}>
|
||||
<span className={sectionTitleClass}>Rewards points</span>
|
||||
<div className={sectionHeaderClass}>
|
||||
<span className={sectionTitleClass}>Rewards points</span>
|
||||
</div>
|
||||
<div className={fieldRowClass}>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
@@ -585,7 +620,7 @@ export function ConfigPanel({
|
||||
<span className="text-sm font-medium">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className={labelClass}>Redemption rate (%)</span>
|
||||
<span className={labelClass}>Redemption rate</span>
|
||||
<span className="text-sm font-medium">{formatPercent(redemptionRate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -603,15 +638,6 @@ export function ConfigPanel({
|
||||
onBlur={handleFieldBlur}
|
||||
/>
|
||||
</div>
|
||||
{typeof recommendedPointDollarValue === 'number' && (
|
||||
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||
{recommendedDiffers && onApplyRecommendedPointDollarValue && (
|
||||
<Button variant="outline" size="sm" onClick={onApplyRecommendedPointDollarValue}>
|
||||
Use recommended
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { Fragment } from "react";
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
import { DiscountSimulationBucket } from "@/types/discount-simulator";
|
||||
import { formatCurrency, formatNumber } from "@/utils/productUtils";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// Utility function to interpolate between two colors
|
||||
const interpolateColor = (color1: [number, number, number], color2: [number, number, number], ratio: number): [number, number, number] => {
|
||||
@@ -63,36 +66,81 @@ interface ResultsTableProps {
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const rowLabels = [
|
||||
// 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 },
|
||||
type MetricGroup =
|
||||
| "orderVolume"
|
||||
| "customerSpend"
|
||||
| "discounts"
|
||||
| "shipping"
|
||||
| "costs"
|
||||
| "outcomes";
|
||||
|
||||
const metricGroupStyles: Record<MetricGroup, { label: string; indicatorClass: string; badgeClass: string; rowClass: string }> = {
|
||||
orderVolume: {
|
||||
label: "Order Volume",
|
||||
indicatorClass: "bg-sky-500",
|
||||
badgeClass: "bg-sky-100 text-sky-900 border-sky-200 uppercase",
|
||||
rowClass: "bg-sky-50",
|
||||
},
|
||||
customerSpend: {
|
||||
label: "Customer Spend",
|
||||
indicatorClass: "bg-indigo-500",
|
||||
badgeClass: "bg-indigo-100 text-indigo-900 border-indigo-200 uppercase",
|
||||
rowClass: "bg-indigo-50",
|
||||
},
|
||||
discounts: {
|
||||
label: "Discounts",
|
||||
indicatorClass: "bg-amber-500",
|
||||
badgeClass: "bg-amber-100 text-amber-900 border-amber-200 uppercase",
|
||||
rowClass: "bg-amber-50",
|
||||
},
|
||||
shipping: {
|
||||
label: "Shipping Impact",
|
||||
indicatorClass: "bg-cyan-500",
|
||||
badgeClass: "bg-cyan-100 text-cyan-900 border-cyan-200 uppercase",
|
||||
rowClass: "bg-cyan-50",
|
||||
},
|
||||
costs: {
|
||||
label: "Cost Inputs",
|
||||
indicatorClass: "bg-rose-500",
|
||||
badgeClass: "bg-rose-100 text-rose-900 border-rose-200 uppercase",
|
||||
rowClass: "bg-rose-50",
|
||||
},
|
||||
outcomes: {
|
||||
label: "Results",
|
||||
indicatorClass: "bg-emerald-600",
|
||||
badgeClass: "bg-emerald-100 text-emerald-900 border-emerald-200 uppercase",
|
||||
rowClass: "bg-emerald-50",
|
||||
},
|
||||
};
|
||||
|
||||
interface RowConfig {
|
||||
key: keyof DiscountSimulationBucket;
|
||||
label: string;
|
||||
format: (value: number) => string;
|
||||
important?: boolean;
|
||||
isCalculated: boolean;
|
||||
group: MetricGroup;
|
||||
}
|
||||
|
||||
const rowLabels: RowConfig[] = [
|
||||
{ key: "orderCount", label: "Orders", format: (value: number) => formatNumber(value), isCalculated: false, group: "orderVolume" },
|
||||
{ key: "weight", label: "Weight", format: (value: number) => `${(value * 100).toFixed(2)}%`, isCalculated: false, group: "orderVolume" },
|
||||
{ key: "orderValue", label: "Order Value", format: (value: number) => formatCurrency(value), isCalculated: false, group: "customerSpend" },
|
||||
{ key: "customerItemCost", label: "Cust Item Cost", format: (value: number) => formatCurrency(value), isCalculated: true, group: "customerSpend" },
|
||||
{ key: "customerShipCost", label: "Cust Ship Cost", format: (value: number) => formatCurrency(value), isCalculated: true, group: "customerSpend" },
|
||||
{ key: "productDiscountAmount", label: "Prod Discount", format: (value: number) => formatCurrency(value), isCalculated: true, group: "discounts" },
|
||||
{ key: "promoProductDiscount", label: "Promo Prod Disc", format: (value: number) => formatCurrency(value), isCalculated: true, group: "discounts" },
|
||||
{ key: "shipPromoDiscount", label: "Ship Promo Disc", format: (value: number) => formatCurrency(value), isCalculated: true, group: "discounts" },
|
||||
{ key: "shippingAfterAuto", label: "Ship Charge", format: (value: number) => formatCurrency(value), isCalculated: true, group: "shipping" },
|
||||
{ key: "actualShippingCost", label: "Actual Ship Cost", format: (value: number) => formatCurrency(value), isCalculated: false, group: "shipping" },
|
||||
{ key: "productCogs", label: "Product COGS", format: (value: number) => formatCurrency(value), isCalculated: false, group: "costs" },
|
||||
{ key: "merchantFees", label: "Merchant Fees", format: (value: number) => formatCurrency(value), isCalculated: true, group: "costs" },
|
||||
{ key: "pointsCost", label: "Points Cost", format: (value: number) => formatCurrency(value), isCalculated: true, group: "costs" },
|
||||
{ key: "fixedCosts", label: "Fixed Cost", format: (value: number) => formatCurrency(value), isCalculated: true, group: "costs" },
|
||||
{ key: "totalRevenue", label: "Total Revenue", format: (value: number) => formatCurrency(value), important: true, isCalculated: true, group: "outcomes" },
|
||||
{ key: "totalCosts", label: "Total Cost", format: (value: number) => formatCurrency(value), important: true, isCalculated: true, group: "outcomes" },
|
||||
{ key: "profit", label: "Profit $", format: (value: number) => formatCurrency(value), important: true, isCalculated: true, group: "outcomes" },
|
||||
{ key: "profitPercent", label: "Profit %", format: (value: number) => `${(value * 100).toFixed(2)}%`, important: true, isCalculated: true, group: "outcomes" },
|
||||
];
|
||||
|
||||
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
|
||||
@@ -123,7 +171,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||
<div className="overflow-x-auto max-w-full">
|
||||
<Table className="text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: '152px' }} />
|
||||
<col style={{ width: '156px' }} />
|
||||
{buckets.map((bucket) => (
|
||||
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} />
|
||||
))}
|
||||
@@ -146,51 +194,119 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rowLabels.map((row) => (
|
||||
<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);
|
||||
|
||||
// Apply color gradient for profit percentage
|
||||
if (row.key === 'profitPercent') {
|
||||
const backgroundColor = getProfitPercentageColor(value);
|
||||
return (
|
||||
<TableCell key={`${row.key}-${bucket.key}`} className={`text-center whitespace-nowrap px-1 py-1 ${row.important ? 'font-semibold' : ''}`}>
|
||||
<span
|
||||
className="inline-block px-1.5 py-0.5 rounded text-white font-medium"
|
||||
style={{ backgroundColor }}
|
||||
rowLabels.map((row, index) => {
|
||||
const groupStyle = metricGroupStyles[row.group];
|
||||
const isImportant = Boolean(row.important);
|
||||
const prevGroup = index > 0 ? rowLabels[index - 1].group : null;
|
||||
const isFirstOfGroup = prevGroup !== row.group;
|
||||
|
||||
return (
|
||||
<Fragment key={`${row.group}-${row.key}`}>
|
||||
{isFirstOfGroup && (
|
||||
<TableRow className={cn(groupStyle.badgeClass)}>
|
||||
<TableCell
|
||||
colSpan={buckets.length + 1}
|
||||
className={cn(
|
||||
"border-0 px-3 py-0",
|
||||
groupStyle.badgeClass,
|
||||
index !== 0 && "pt-0"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div
|
||||
|
||||
className={cn(
|
||||
"px-2 py-1 font-semibold tracking-wide text-xs",
|
||||
groupStyle.badgeClass
|
||||
)}
|
||||
>
|
||||
{groupStyle.label}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
<TableRow className={cn("align-top", groupStyle.rowClass)}>
|
||||
<TableCell
|
||||
className={cn(
|
||||
"sticky left-0 z-10 border-r px-3 py-2",
|
||||
groupStyle.rowClass,
|
||||
isImportant ? "font-semibold text-emerald-800" : "font-medium text-slate-700"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn("h-4 w-1 rounded-full", groupStyle.indicatorClass)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm leading-tight pr-2",
|
||||
isImportant ? "font-semibold text-emerald-900" : "font-medium text-slate-800"
|
||||
)}
|
||||
>
|
||||
{row.label}
|
||||
</span>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block h-2.5 w-2.5 rounded-full shadow-sm absolute right-1 top-1/2 -translate-y-1/2",
|
||||
row.isCalculated ? "bg-blue-400" : "bg-emerald-400"
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{row.isCalculated ? "Calculated value" : "Database value"}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</TableCell>
|
||||
{buckets.map((bucket) => {
|
||||
const value = bucket[row.key as keyof DiscountSimulationBucket] as number;
|
||||
const formattedValue = row.format(value);
|
||||
|
||||
if (row.key === "profitPercent") {
|
||||
const backgroundColor = getProfitPercentageColor(value);
|
||||
return (
|
||||
<TableCell
|
||||
key={`${row.key}-${bucket.key}`}
|
||||
className={cn(
|
||||
"text-center whitespace-nowrap px-1 py-1 text-slate-700",
|
||||
groupStyle.rowClass,
|
||||
isImportant && "font-semibold text-emerald-700"
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className="inline-block rounded px-1.5 py-0.5 text-white font-medium"
|
||||
style={{ backgroundColor }}
|
||||
>
|
||||
{formattedValue}
|
||||
</span>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell
|
||||
key={`${row.key}-${bucket.key}`}
|
||||
className={cn(
|
||||
"text-center whitespace-nowrap px-1 py-1 text-slate-700",
|
||||
groupStyle.rowClass,
|
||||
isImportant && "font-semibold text-emerald-700"
|
||||
)}
|
||||
>
|
||||
{formattedValue}
|
||||
</span>
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableCell key={`${row.key}-${bucket.key}`} className={`text-center whitespace-nowrap px-1 py-1 ${row.important ? 'font-semibold text-primary' : ''}`}>
|
||||
{formattedValue}
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))
|
||||
</TableCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
</Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -29,7 +29,7 @@ export function DateRangePicker({
|
||||
id="date"
|
||||
variant={"outline"}
|
||||
className={cn(
|
||||
"h-8 w-full justify-start text-left font-normal",
|
||||
"h-8 px-2 justify-start text-left font-normal",
|
||||
!value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user