first
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { TrendingUp, TrendingDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Card, CardBody } from "./Card";
|
||||
import { Badge } from "./Badge";
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
delta,
|
||||
tone = "neutral",
|
||||
icon,
|
||||
subtitle,
|
||||
isPercentage,
|
||||
className,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
delta?: number; // percentage change, e.g. +5.2
|
||||
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "violet" | "blue";
|
||||
icon?: React.ReactNode;
|
||||
subtitle?: string;
|
||||
isPercentage?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
const showValue = isPercentage
|
||||
? `${Number(value).toFixed(1)}%`
|
||||
: typeof value === "number"
|
||||
? value.toLocaleString()
|
||||
: value;
|
||||
|
||||
return (
|
||||
<Card className={cn("relative overflow-hidden", className)}>
|
||||
<CardBody className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-slate-500 uppercase tracking-wide">{label}</div>
|
||||
<div className="mt-1.5 text-2xl font-semibold text-slate-900 tabular-nums">
|
||||
{showValue}
|
||||
</div>
|
||||
{subtitle !== undefined && (
|
||||
<div className="mt-1 text-xs text-slate-500">{subtitle}</div>
|
||||
)}
|
||||
{delta !== undefined && (
|
||||
<div className="mt-2 inline-flex items-center gap-1 text-xs">
|
||||
{delta >= 0 ? (
|
||||
<>
|
||||
<TrendingUp className="w-3.5 h-3.5 text-emerald-600" />
|
||||
<span className="text-emerald-700 font-medium">+{delta.toFixed(1)}%</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TrendingDown className="w-3.5 h-3.5 text-red-600" />
|
||||
<span className="text-red-700 font-medium">{delta.toFixed(1)}%</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-slate-400 ml-1">vs last period</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{icon && (
|
||||
<div
|
||||
className={cn(
|
||||
"w-10 h-10 rounded-lg grid place-items-center shrink-0 border",
|
||||
tone === "brand" && "bg-brand-50 border-brand-100 text-brand-600",
|
||||
tone === "green" && "bg-emerald-50 border-emerald-100 text-emerald-600",
|
||||
tone === "red" && "bg-red-50 border-red-100 text-red-600",
|
||||
tone === "amber" && "bg-amber-50 border-amber-100 text-amber-600",
|
||||
tone === "violet" && "bg-violet-50 border-violet-100 text-violet-600",
|
||||
tone === "blue" && "bg-sky-50 border-sky-100 text-sky-600",
|
||||
tone === "neutral" && "bg-slate-50 border-slate-200 text-slate-500",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function MetricPill({
|
||||
label,
|
||||
value,
|
||||
tone = "neutral",
|
||||
isPercentage,
|
||||
}: {
|
||||
label: string;
|
||||
value: number | string;
|
||||
tone?: "neutral" | "brand" | "green" | "red" | "amber" | "blue";
|
||||
isPercentage?: boolean;
|
||||
}) {
|
||||
const shown = isPercentage ? `${Number(value).toFixed(1)}%` : value;
|
||||
const toneMap: Record<typeof tone, string> = {
|
||||
neutral: "bg-slate-100 text-slate-700",
|
||||
brand: "bg-brand-50 text-brand-700",
|
||||
blue: "bg-sky-50 text-sky-700",
|
||||
green: "bg-emerald-50 text-emerald-700",
|
||||
red: "bg-red-50 text-red-700",
|
||||
amber: "bg-amber-50 text-amber-700",
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{label}</span>
|
||||
<Badge tone={tone === "neutral" ? "neutral" : tone === "brand" ? "brand" : tone === "green" ? "green" : tone === "red" ? "red" : "amber"}>
|
||||
<span className="tabular-nums font-semibold">{shown}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user