Files
template-dashboard/src/components/KpiCard.tsx
T

125 lines
4.1 KiB
TypeScript

// ---------------------------------------------------------------------------
// KpiCard — pre-built KPI metric card wired to AnyDB aggregate
// ---------------------------------------------------------------------------
// PRE-INSTALLED. CodeWriterAgent just uses:
// import { KpiCard } from "@/components/KpiCard"
// <KpiCard collection="orders" metric="count" label="Total Orders" icon={ShoppingBag} />
// <KpiCard collection="orders" metric="sum" field="total_cents" label="Revenue" format="currency" icon={DollarSign} />
// <KpiCard collection="users" metric="count" filter={{ plan: 'pro' }} label="Pro Users" icon={Users} />
// ---------------------------------------------------------------------------
import { useMemo } from "react";
import { useAggregate, useCollection } from "@/hooks/use-anydb";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { LucideIcon } from "lucide-react";
interface KpiCardProps {
collection: string;
metric: "count" | "sum" | "avg" | "min" | "max";
field?: string;
filter?: Record<string, any>;
label: string;
icon?: LucideIcon;
format?: "number" | "currency" | "percent";
prefix?: string;
suffix?: string;
trend?: number; // e.g. 12.5 for +12.5%
className?: string;
}
export function KpiCard({
collection,
metric,
field,
filter = {},
label,
icon: Icon,
format = "number",
prefix = "",
suffix = "",
trend,
className = "",
}: KpiCardProps) {
const pipeline = useMemo(() => {
const stages: any[] = [];
if (Object.keys(filter).length > 0) {
stages.push({ $match: filter });
}
switch (metric) {
case "count":
stages.push({ $count: "value" });
break;
case "sum":
stages.push({ $group: { _id: null, value: { $sum: `$${field}` } } });
break;
case "avg":
stages.push({ $group: { _id: null, value: { $avg: `$${field}` } } });
break;
case "min":
stages.push({ $group: { _id: null, value: { $min: `$${field}` } } });
break;
case "max":
stages.push({ $group: { _id: null, value: { $max: `$${field}` } } });
break;
}
return stages;
}, [metric, field, JSON.stringify(filter)]);
// For count, we can use the simpler useCollection total
const useSimpleCount = metric === "count" && Object.keys(filter).length === 0;
const { total: simpleTotal, loading: simpleLoading } = useCollection(collection, {
limit: 1,
autoFetch: useSimpleCount,
});
const { data: aggData, loading: aggLoading } = useAggregate(collection, pipeline);
const loading = useSimpleCount ? simpleLoading : aggLoading;
const rawValue = useSimpleCount
? simpleTotal
: aggData?.[0]?.value ?? 0;
const formatValue = (val: number): string => {
switch (format) {
case "currency":
return `$${(val / 100).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
case "percent":
return `${val.toFixed(1)}%`;
default:
return val >= 1000 ? `${(val / 1000).toFixed(1)}K` : val.toLocaleString();
}
};
return (
<Card className={`${className}`}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm text-muted-foreground">{label}</p>
{loading ? (
<Skeleton className="h-8 w-24" />
) : (
<p className="text-2xl font-bold tabular-nums">
{prefix}{formatValue(rawValue)}{suffix}
</p>
)}
{trend !== undefined && !loading && (
<p className={`text-xs font-medium ${trend >= 0 ? "text-emerald-600" : "text-red-500"}`}>
{trend >= 0 ? "↑" : "↓"} {Math.abs(trend)}%
</p>
)}
</div>
{Icon && (
<div className="h-12 w-12 rounded-xl bg-primary/10 flex items-center justify-center shrink-0">
<Icon className="h-6 w-6 text-primary" />
</div>
)}
</div>
</CardContent>
</Card>
);
}