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 { 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, DiscountSimulationResponse, CogsCalculationMode } from "@/types/discount-simulator";
|
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 { formatNumber } from "@/utils/productUtils";
|
||||||
import { PlusIcon, X } from "lucide-react";
|
import { PlusIcon, X } from "lucide-react";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
@@ -118,8 +116,6 @@ export function ConfigPanel({
|
|||||||
promoLoading = false,
|
promoLoading = false,
|
||||||
onRunSimulation,
|
onRunSimulation,
|
||||||
isRunning,
|
isRunning,
|
||||||
recommendedPointDollarValue,
|
|
||||||
onApplyRecommendedPointDollarValue,
|
|
||||||
result
|
result
|
||||||
}: ConfigPanelProps) {
|
}: ConfigPanelProps) {
|
||||||
const promoOptions = useMemo(() => {
|
const promoOptions = useMemo(() => {
|
||||||
@@ -171,21 +167,22 @@ export function ConfigPanel({
|
|||||||
if (!current) {
|
if (!current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tierId = current.id ?? generateTierId();
|
const tierId = current.id ?? generateTierId();
|
||||||
tiers[index] = {
|
const mergedTier = {
|
||||||
...current,
|
...current,
|
||||||
...update,
|
...update,
|
||||||
id: tierId,
|
id: tierId,
|
||||||
};
|
};
|
||||||
const sorted = tiers
|
|
||||||
.filter((tier) => tier != null)
|
const normalizedTier: ShippingTierConfig = {
|
||||||
.map((tier) => ({
|
...mergedTier,
|
||||||
...tier,
|
threshold: Number.isFinite(mergedTier.threshold) ? mergedTier.threshold ?? 0 : 0,
|
||||||
threshold: Number.isFinite(tier.threshold) ? tier.threshold : 0,
|
value: Number.isFinite(mergedTier.value) ? mergedTier.value ?? 0 : 0,
|
||||||
value: Number.isFinite(tier.value) ? tier.value : 0,
|
};
|
||||||
}))
|
|
||||||
.sort((a, b) => a.threshold - b.threshold);
|
tiers[index] = normalizedTier;
|
||||||
onShippingTiersChange(sorted);
|
onShippingTiersChange(tiers);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTierRemove = (index: number) => {
|
const handleTierRemove = (index: number) => {
|
||||||
@@ -209,118 +206,152 @@ export function ConfigPanel({
|
|||||||
onShippingTiersChange(tiers);
|
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(() => {
|
const handleFieldBlur = useCallback(() => {
|
||||||
setTimeout(onRunSimulation, 0);
|
setTimeout(onRunSimulation, 0);
|
||||||
}, [onRunSimulation]);
|
}, [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 (
|
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-3 px-4 py-4">
|
||||||
<div className="space-y-6">
|
<div className="space-y-4">
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
<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=""
|
||||||
</div>
|
/>
|
||||||
<div className={fieldClass}>
|
</div>
|
||||||
<Label className={labelClass}>Promo code</Label>
|
<div className={fieldClass}>
|
||||||
<Select
|
<Label className={labelClass}>Promo code</Label>
|
||||||
value={promoSelectValue}
|
<Select
|
||||||
onValueChange={(value) => {
|
value={promoSelectValue}
|
||||||
if (value === '__all__') {
|
onValueChange={(value) => {
|
||||||
onPromoChange(undefined);
|
if (value === '__all__') {
|
||||||
return;
|
onPromoChange(undefined);
|
||||||
}
|
return;
|
||||||
const parsed = Number(value);
|
}
|
||||||
onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
|
const parsed = Number(value);
|
||||||
}}
|
onPromoChange(Number.isNaN(parsed) ? undefined : parsed);
|
||||||
>
|
}}
|
||||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
>
|
||||||
{promoLoading ? (
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
<div className="flex w-full items-center">
|
{promoLoading ? (
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<div className="flex w-full items-center">
|
||||||
</div>
|
<Skeleton className="h-4 w-3/4" />
|
||||||
) : (
|
|
||||||
<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>
|
</div>
|
||||||
</SelectItem>
|
) : (
|
||||||
))}
|
<SelectValue placeholder="All promos">
|
||||||
</SelectContent>
|
{promoSelectValue === "__all__"
|
||||||
</Select>
|
? "All promos"
|
||||||
</div>
|
: 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 && (
|
{result?.totals && (
|
||||||
<div className="flex flex-wrap gap-2 mt-3">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Badge variant="secondary" className="text-xs">
|
<div className={`${metricPillClass} whitespace-nowrap`}>
|
||||||
{formatNumber(result.totals.orders)} orders
|
<span className="text-xs font-semibold">{formatNumber(result.totals.orders)}</span>
|
||||||
</Badge>
|
<span className="text-[0.6rem] uppercase text-muted-foreground">orders</span>
|
||||||
<Badge variant="secondary" className="text-xs">
|
</div>
|
||||||
{formatPercent(result.totals.productDiscountRate)} avg discount
|
<div className={`${metricPillClass} whitespace-nowrap`}>
|
||||||
</Badge>
|
<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 && (
|
{result.totals.overallCogsPercentage != null && (
|
||||||
<Badge variant="secondary" className="text-xs">
|
<div className={`${metricPillClass} whitespace-nowrap`}>
|
||||||
{formatPercent(result.totals.overallCogsPercentage)} avg COGS
|
<span className="text-xs font-semibold">{formatPercent(result.totals.overallCogsPercentage)}</span>
|
||||||
</Badge>
|
<span className="text-[0.6rem] uppercase text-muted-foreground">avg COGS</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
<Separator />
|
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
<span className={sectionTitleClass}>Product promo</span>
|
<div className={sectionHeaderClass}>
|
||||||
|
<span className={sectionTitleClass}>Product promo</span>
|
||||||
|
</div>
|
||||||
<div className={fieldRowClass}>
|
<div className={fieldRowClass}>
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
<Label className={labelClass}>Promo type</Label>
|
<Label className={labelClass}>Promo type</Label>
|
||||||
<Select
|
<Select
|
||||||
value={productPromo.type}
|
value={productPromo.type}
|
||||||
onValueChange={(value) => onProductPromoChange({ type: value as DiscountPromoType })}
|
onValueChange={(value) => onProductPromoChange({ type: value as DiscountPromoType })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">No additional promo</SelectItem>
|
<SelectItem value="none">No additional promo</SelectItem>
|
||||||
<SelectItem value="percentage_subtotal">% off subtotal</SelectItem>
|
<SelectItem value="percentage_subtotal">% off subtotal</SelectItem>
|
||||||
<SelectItem value="percentage_regular">% off regular price</SelectItem>
|
<SelectItem value="percentage_regular">% off regular price</SelectItem>
|
||||||
<SelectItem value="fixed_amount">Fixed dollar discount</SelectItem>
|
<SelectItem value="fixed_amount">Fixed dollar discount</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{showProductAdjustments && (
|
{showProductAdjustments && (
|
||||||
<div className={fieldRowHorizontalClass}>
|
<div className={fieldRowHorizontalClass}>
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
@@ -359,24 +390,26 @@ export function ConfigPanel({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
<span className={sectionTitleClass}>Shipping promo</span>
|
<div className={sectionHeaderClass}>
|
||||||
|
<span className={sectionTitleClass}>Shipping promo</span>
|
||||||
|
</div>
|
||||||
<div className={fieldRowClass}>
|
<div className={fieldRowClass}>
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
<Label className={labelClass}>Promo type</Label>
|
<Label className={labelClass}>Promo type</Label>
|
||||||
<Select
|
<Select
|
||||||
value={shippingPromo.type}
|
value={shippingPromo.type}
|
||||||
onValueChange={(value) => onShippingPromoChange({ type: value as ShippingPromoType })}
|
onValueChange={(value) => onShippingPromoChange({ type: value as ShippingPromoType })}
|
||||||
>
|
>
|
||||||
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
<SelectTrigger className={`${compactTriggerClass} w-full`}>
|
||||||
<SelectValue />
|
<SelectValue />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="none">No shipping promo</SelectItem>
|
<SelectItem value="none">No shipping promo</SelectItem>
|
||||||
<SelectItem value="percentage">% off shipping charge</SelectItem>
|
<SelectItem value="percentage">% off shipping charge</SelectItem>
|
||||||
<SelectItem value="fixed">Fixed dollar discount</SelectItem>
|
<SelectItem value="fixed">Fixed dollar discount</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{showShippingAdjustments && (
|
{showShippingAdjustments && (
|
||||||
<>
|
<>
|
||||||
<div className={fieldRowHorizontalClass}>
|
<div className={fieldRowHorizontalClass}>
|
||||||
@@ -430,100 +463,100 @@ export function ConfigPanel({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="flex flex-col gap-2">
|
<section className={compactSectionClass}>
|
||||||
<div className="flex items-center justify-between">
|
<div className={sectionHeaderClass}>
|
||||||
<span className={sectionTitleClass}>Shipping tiers</span>
|
<span className={sectionTitleClass}>Shipping tiers</span>
|
||||||
<Button variant="outline" size="sm" onClick={handleTierAdd} className="flex items-center gap-1">
|
<Button variant="outline" size="sm" onClick={handleTierAdd} className="flex items-center gap-1">
|
||||||
<PlusIcon className="w-3 h-3" />
|
<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="">
|
<ScrollArea>
|
||||||
<div className="flex flex-col gap-2 pr-1">
|
<div className="flex flex-col gap-2 pr-1 -mx-2">
|
||||||
{/* Header row */}
|
<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 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>$ Amount</div>
|
<div>Type</div>
|
||||||
<div>Type</div>
|
<div>Value</div>
|
||||||
<div>Value</div>
|
<div className="w-1.5" aria-hidden="true" />
|
||||||
<div className="w-1.5"></div>
|
</div>
|
||||||
|
{shippingTiers.map((tier, index) => {
|
||||||
|
const tierKey = tier.id ?? `tier-${index}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={tierKey}
|
||||||
|
className="relative grid gap-2 rounded 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>
|
</div>
|
||||||
{shippingTiers.map((tier, index) => {
|
</ScrollArea>
|
||||||
const tierKey = tier.id ?? `tier-${index}`;
|
)}
|
||||||
return (
|
</section>
|
||||||
<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 />
|
|
||||||
|
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
<span className={sectionTitleClass}>Order costs</span>
|
<div className={sectionHeaderClass}>
|
||||||
|
<span className={sectionTitleClass}>Order costs</span>
|
||||||
|
</div>
|
||||||
<div className={fieldRowClass}>
|
<div className={fieldRowClass}>
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
<Label className={labelClass}>COGS calculation</Label>
|
<Label className={labelClass}>COGS calculation</Label>
|
||||||
@@ -559,7 +592,7 @@ export function ConfigPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className={fieldClass}>
|
<div className={fieldClass}>
|
||||||
<Label className={labelClass}>Fixed cost/order ($)</Label>
|
<Label className={labelClass}>Fixed costs ($)</Label>
|
||||||
<Input
|
<Input
|
||||||
className={compactNumberClass}
|
className={compactNumberClass}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -577,7 +610,9 @@ export function ConfigPanel({
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={sectionClass}>
|
<section className={sectionClass}>
|
||||||
<span className={sectionTitleClass}>Rewards points</span>
|
<div className={sectionHeaderClass}>
|
||||||
|
<span className={sectionTitleClass}>Rewards points</span>
|
||||||
|
</div>
|
||||||
<div className={fieldRowClass}>
|
<div className={fieldRowClass}>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<div className="flex flex-col gap-1.5">
|
<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>
|
<span className="text-sm font-medium">{Number.isFinite(pointsPerDollar) ? pointsPerDollar.toFixed(4) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1.5">
|
<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>
|
<span className="text-sm font-medium">{formatPercent(redemptionRate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -603,15 +638,6 @@ export function ConfigPanel({
|
|||||||
onBlur={handleFieldBlur}
|
onBlur={handleFieldBlur}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -16,6 +18,7 @@ import {
|
|||||||
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";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// 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] => {
|
||||||
@@ -63,36 +66,81 @@ interface ResultsTableProps {
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rowLabels = [
|
type MetricGroup =
|
||||||
// Most important metrics first - prominently styled
|
| "orderVolume"
|
||||||
{ key: "totalRevenue", label: "Total Revenue", format: (value: number) => formatCurrency(value), important: true, isCalculated: true },
|
| "customerSpend"
|
||||||
{ key: "totalCosts", label: "Total Cost", format: (value: number) => formatCurrency(value), important: true, isCalculated: true },
|
| "discounts"
|
||||||
{ key: "profit", label: "Profit $", format: (value: number) => formatCurrency(value), important: true, isCalculated: true },
|
| "shipping"
|
||||||
{ key: "profitPercent", label: "Profit %", format: (value: number) => `${(value * 100).toFixed(2)}%`, important: true, isCalculated: true },
|
| "costs"
|
||||||
|
| "outcomes";
|
||||||
|
|
||||||
// Order metrics - from database
|
const metricGroupStyles: Record<MetricGroup, { label: string; indicatorClass: string; badgeClass: string; rowClass: string }> = {
|
||||||
{ key: "orderCount", label: "Orders", format: (value: number) => formatNumber(value), important: false, isCalculated: false },
|
orderVolume: {
|
||||||
{ key: "weight", label: "Weight", format: (value: number) => `${(value * 100).toFixed(2)}%`, important: false, isCalculated: false },
|
label: "Order Volume",
|
||||||
{ key: "orderValue", label: "Order Value", format: (value: number) => formatCurrency(value), important: false, isCalculated: false },
|
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",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Customer costs
|
interface RowConfig {
|
||||||
{ key: "customerItemCost", label: "Cust Item Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
key: keyof DiscountSimulationBucket;
|
||||||
{ key: "customerShipCost", label: "Cust Ship Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
label: string;
|
||||||
|
format: (value: number) => string;
|
||||||
|
important?: boolean;
|
||||||
|
isCalculated: boolean;
|
||||||
|
group: MetricGroup;
|
||||||
|
}
|
||||||
|
|
||||||
// Discounts
|
const rowLabels: RowConfig[] = [
|
||||||
{ key: "productDiscountAmount", label: "Prod Discount", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
{ key: "orderCount", label: "Orders", format: (value: number) => formatNumber(value), isCalculated: false, group: "orderVolume" },
|
||||||
{ key: "promoProductDiscount", label: "Promo Prod Disc", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
{ key: "weight", label: "Weight", format: (value: number) => `${(value * 100).toFixed(2)}%`, isCalculated: false, group: "orderVolume" },
|
||||||
{ key: "shipPromoDiscount", label: "Ship Promo Disc", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
{ 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" },
|
||||||
// Shipping
|
{ key: "customerShipCost", label: "Cust Ship Cost", format: (value: number) => formatCurrency(value), isCalculated: true, group: "customerSpend" },
|
||||||
{ key: "shippingAfterAuto", label: "Ship Charge", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
{ key: "productDiscountAmount", label: "Prod Discount", format: (value: number) => formatCurrency(value), isCalculated: true, group: "discounts" },
|
||||||
{ key: "actualShippingCost", label: "Actual Ship Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: false },
|
{ 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" },
|
||||||
// Cost breakdown
|
{ key: "shippingAfterAuto", label: "Ship Charge", format: (value: number) => formatCurrency(value), isCalculated: true, group: "shipping" },
|
||||||
{ key: "productCogs", label: "Product COGS", format: (value: number) => formatCurrency(value), important: false, isCalculated: false },
|
{ key: "actualShippingCost", label: "Actual Ship Cost", format: (value: number) => formatCurrency(value), isCalculated: false, group: "shipping" },
|
||||||
{ key: "merchantFees", label: "Merchant Fees", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
{ key: "productCogs", label: "Product COGS", format: (value: number) => formatCurrency(value), isCalculated: false, group: "costs" },
|
||||||
{ key: "pointsCost", label: "Points Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
{ key: "merchantFees", label: "Merchant Fees", format: (value: number) => formatCurrency(value), isCalculated: true, group: "costs" },
|
||||||
{ key: "fixedCosts", label: "Fixed Cost", format: (value: number) => formatCurrency(value), important: false, isCalculated: true },
|
{ 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) => {
|
const formatRangeUpperBound = (bucket: DiscountSimulationBucket) => {
|
||||||
@@ -123,7 +171,7 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
|||||||
<div className="overflow-x-auto max-w-full">
|
<div className="overflow-x-auto max-w-full">
|
||||||
<Table className="text-sm table-fixed">
|
<Table className="text-sm table-fixed">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '152px' }} />
|
<col style={{ width: '156px' }} />
|
||||||
{buckets.map((bucket) => (
|
{buckets.map((bucket) => (
|
||||||
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} />
|
<col key={bucket.key} style={{ minWidth: '70px', width: '75px' }} />
|
||||||
))}
|
))}
|
||||||
@@ -146,51 +194,119 @@ export function ResultsTable({ buckets, isLoading }: ResultsTableProps) {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rowLabels.map((row) => (
|
rowLabels.map((row, index) => {
|
||||||
<TableRow key={row.key} className={row.important ? "bg-muted/30 border-l-4 border-l-primary" : ""}>
|
const groupStyle = metricGroupStyles[row.group];
|
||||||
<TableCell className={`font-medium sticky left-0 bg-background z-10 px-3 py-2 border-r ${row.important ? 'font-semibold text-primary' : ''}`}>
|
const isImportant = Boolean(row.important);
|
||||||
<div className="flex items-center gap-2">
|
const prevGroup = index > 0 ? rowLabels[index - 1].group : null;
|
||||||
{/* Subtle indicator for data source */}
|
const isFirstOfGroup = prevGroup !== row.group;
|
||||||
<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
|
return (
|
||||||
if (row.key === 'profitPercent') {
|
<Fragment key={`${row.group}-${row.key}`}>
|
||||||
const backgroundColor = getProfitPercentageColor(value);
|
{isFirstOfGroup && (
|
||||||
return (
|
<TableRow className={cn(groupStyle.badgeClass)}>
|
||||||
<TableCell key={`${row.key}-${bucket.key}`} className={`text-center whitespace-nowrap px-1 py-1 ${row.important ? 'font-semibold' : ''}`}>
|
<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
|
<span
|
||||||
className="inline-block px-1.5 py-0.5 rounded text-white font-medium"
|
aria-hidden
|
||||||
style={{ backgroundColor }}
|
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}
|
{formattedValue}
|
||||||
</span>
|
</TableCell>
|
||||||
</TableCell>
|
);
|
||||||
);
|
})}
|
||||||
}
|
</TableRow>
|
||||||
|
</Fragment>
|
||||||
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>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function DateRangePicker({
|
|||||||
id="date"
|
id="date"
|
||||||
variant={"outline"}
|
variant={"outline"}
|
||||||
className={cn(
|
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"
|
!value && "text-muted-foreground"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user