112 lines
3.7 KiB
TypeScript
112 lines
3.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|