ui fixers in 10/16
This commit is contained in:
@@ -22,7 +22,7 @@ export function KpiRow({ tiles }: { tiles: KpiTileSpec[] | null }) {
|
||||
? tiles.map((tile) => (
|
||||
<StatCard key={tile.label} label={tile.label} value={tile.value} icon={tile.icon} />
|
||||
))
|
||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-[90px] rounded-xl" />)}
|
||||
: Array.from({ length: 2 }).map((_, i) => <Skeleton key={i} className="h-24 rounded-2xl" />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,63 +46,86 @@ export interface StatCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon?: LucideIcon
|
||||
/** Small muted line under the value, e.g. "All time" or "3 with balance due". */
|
||||
hint?: string
|
||||
delta?: number
|
||||
deltaLabel?: string
|
||||
/** Set true for metrics where an increase is bad (e.g. churn, costs). */
|
||||
invert?: boolean
|
||||
trend?: number[]
|
||||
className?: string
|
||||
/** Extra classes for the value, e.g. a success/destructive colour. */
|
||||
valueClassName?: string
|
||||
/** Extra classes for the icon tile, e.g. a warning tint instead of the primary one. */
|
||||
iconClassName?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's KPI tile: a tinted icon square on the left, then label / bold value / optional
|
||||
* hint stacked beside it. Every list page's stat row uses this so they all read the same.
|
||||
*/
|
||||
export function StatCard({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
hint,
|
||||
delta,
|
||||
deltaLabel,
|
||||
invert = false,
|
||||
trend,
|
||||
className,
|
||||
valueClassName,
|
||||
iconClassName,
|
||||
}: StatCardProps) {
|
||||
const hasDelta = typeof delta === "number" && delta !== 0
|
||||
const isPositive = hasDelta && delta > 0
|
||||
const isGood = hasDelta && (invert ? !isPositive : isPositive)
|
||||
|
||||
return (
|
||||
// A size container: the card reads its *own* width, so it lays out the same whether a page puts
|
||||
// it in a 2-up phone grid or a wide desktop row.
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col gap-1.5 rounded-xl bg-card p-3.5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
"@container min-w-0 rounded-2xl bg-card px-4 py-5 shadow-sm ring-1 ring-foreground/10 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg hover:ring-primary/30",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">{label}</p>
|
||||
{/* Wide enough: icon beside the text. Narrow (e.g. two per row on a phone): icon above, so the
|
||||
value keeps the full width instead of being cut off. */}
|
||||
<div className="flex flex-col gap-2.5 @[12.5rem]:flex-row @[12.5rem]:items-center @[12.5rem]:gap-3.5">
|
||||
{Icon && (
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className="text-lg font-bold tracking-tight text-foreground">{formatValue(value)}</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
</div>
|
||||
|
||||
{hasDelta && (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span
|
||||
<div
|
||||
className={cn(
|
||||
"font-semibold",
|
||||
isGood ? "text-success" : "text-destructive"
|
||||
"flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary @[12.5rem]:size-11",
|
||||
iconClassName
|
||||
)}
|
||||
>
|
||||
{isPositive ? "+" : "-"}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
<Icon className="size-4.5 @[12.5rem]:size-5" aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="truncate text-sm text-muted-foreground">{label}</p>
|
||||
<div className="flex items-end justify-between gap-2">
|
||||
<p className={cn("text-base leading-tight font-semibold tracking-tight wrap-break-word text-foreground @[12.5rem]:text-lg", valueClassName)}>
|
||||
{formatValue(value)}
|
||||
</p>
|
||||
{trend && trend.length > 1 && <Sparkline points={trend} />}
|
||||
</div>
|
||||
|
||||
{hasDelta ? (
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className={cn("font-semibold", isGood ? "text-success" : "text-destructive")}>
|
||||
{isPositive ? "+" : "-"}
|
||||
{Math.abs(delta).toFixed(1)}%
|
||||
</span>
|
||||
{deltaLabel && <span className="text-muted-foreground">{deltaLabel}</span>}
|
||||
</div>
|
||||
) : (
|
||||
hint && <p className="truncate text-xs text-muted-foreground">{hint}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user