Files
property-management-network/components/dashboard/stats-card.tsx
T

147 lines
4.7 KiB
TypeScript
Raw Normal View History

import { cn } from "@/lib/utils"
import type { LucideIcon } from "lucide-react"
import { TrendingUp, TrendingDown, Minus } from "lucide-react"
import { AnimatedNumber } from "@/components/ui/animated-number"
interface StatsCardProps {
label: string
value: string | number
sub?: string
icon: LucideIcon
trend?: { value: number; label: string }
variant?: "default" | "success" | "warning" | "danger"
progress?: number
className?: string
}
export function StatsCard({
label,
value,
sub,
icon: Icon,
trend,
variant = "default",
progress,
className,
}: StatsCardProps) {
const themes = {
default: {
icon: "text-indigo-400 bg-indigo-500/10 border-indigo-500/20",
glow: "group-hover:shadow-indigo-500/10",
bar: "from-indigo-500 to-violet-500",
gradient: "group-hover:from-indigo-500/[0.04]",
},
success: {
icon: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
glow: "group-hover:shadow-emerald-500/10",
bar: "from-emerald-500 to-teal-500",
gradient: "group-hover:from-emerald-500/[0.04]",
},
warning: {
icon: "text-amber-400 bg-amber-500/10 border-amber-500/20",
glow: "group-hover:shadow-amber-500/10",
bar: "from-amber-500 to-orange-500",
gradient: "group-hover:from-amber-500/[0.04]",
},
danger: {
icon: "text-red-400 bg-red-500/10 border-red-500/20",
glow: "group-hover:shadow-red-500/10",
bar: "from-red-500 to-rose-500",
gradient: "group-hover:from-red-500/[0.04]",
},
}
const theme = themes[variant]
const trendColor =
!trend ? "" :
trend.value > 0 ? "text-emerald-400" :
trend.value < 0 ? "text-red-400" : "text-white/40"
const TrendIcon = !trend ? Minus : trend.value > 0 ? TrendingUp : trend.value < 0 ? TrendingDown : Minus
// Detect if value is a plain number (animate) or a formatted string
const isNumeric = typeof value === "number"
// For currency strings like "$4,200" — extract the number to animate
const isCurrencyString = typeof value === "string" && value.startsWith("$")
const numericVal = isNumeric
? value
: isCurrencyString
? parseFloat(value.replace(/[$,]/g, ""))
: null
return (
<div
className={cn(
"group relative overflow-hidden rounded-2xl border border-white/[0.06] bg-[#16161f] p-5 transition-all duration-300",
"hover:border-white/[0.12] hover:shadow-xl hover:shadow-black/30",
theme.glow,
className
)}
>
{/* Hover gradient */}
<div className={cn(
"absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500",
"bg-gradient-to-br to-transparent from-transparent",
theme.gradient
)} />
{/* Top accent line */}
<div className={cn(
"absolute top-0 left-0 right-0 h-[1px] opacity-0 group-hover:opacity-100 transition-opacity duration-500",
"bg-gradient-to-r", theme.bar
)} />
<div className="relative">
{/* Label + icon */}
<div className="flex items-start justify-between">
<p className="text-xs font-medium uppercase tracking-wider text-white/40">{label}</p>
<div className={cn("flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border", theme.icon)}>
<Icon className="h-4 w-4" />
</div>
</div>
{/* Value — animated if numeric */}
<p className="mt-4 text-3xl font-bold tracking-tight text-white tabular-nums">
{numericVal !== null ? (
<AnimatedNumber
value={numericVal}
format={isCurrencyString ? "currency" : "number"}
/>
) : (
value
)}
</p>
{/* Trend */}
{trend && (
<div className="mt-1.5 flex items-center gap-1.5">
<TrendIcon className={cn("h-3.5 w-3.5", trendColor)} />
<span className={cn("text-xs font-semibold tabular-nums", trendColor)}>
{trend.value > 0 ? "+" : ""}{trend.value}%
</span>
<span className="text-xs text-white/30">{trend.label}</span>
</div>
)}
{/* Progress bar */}
{progress !== undefined && (
<div className="mt-4">
<div className="h-1.5 overflow-hidden rounded-full bg-white/[0.06]">
<div
className={cn("h-full rounded-full bg-gradient-to-r transition-all duration-700", theme.bar)}
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
/>
</div>
</div>
)}
{/* Sub */}
{sub && (
<p className="mt-2.5 text-xs text-white/40 leading-snug">{sub}</p>
)}
</div>
</div>
)
}