125 lines
4.1 KiB
TypeScript
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>
|
|
);
|
|
}
|