finai / apps /web /components /dynamic /DynamicReport.tsx
Sandy-sys's picture
Deploy FinAI
db7ef8e verified
Raw History Blame Contribute Delete
14.2 kB
"use client";
import { useState, useMemo } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Download, Copy, Check, Info, ChevronDown, ChevronUp, BarChart3, Eye, EyeOff, Maximize2 } from "lucide-react";
import { ChartRenderer } from "@/components/charts/registry";
import type { UIPlan, ChartComponent } from "./types";
import { CitationsRail } from "@/components/dynamic/CitationsRail";
import { renderRichText } from "./rich";
import { copyToClipboard, downloadFile, rowsToCsv, safeFilename } from "@/lib/export";
const MOOD_TINTS: Record<string, string> = {
calm: "from-mb-paper via-white to-mb-amber-50/30",
urgent: "from-rose-50 via-white to-rose-50/20",
celebratory: "from-yellow-50 via-white to-amber-50/30",
investigative: "from-sky-50/60 via-white to-sky-50/20",
neutral: "from-mb-amber-50/60 via-white to-mb-paper",
};
const PERSONA_ICONS: Record<string, string> = {
analyst: "πŸ“Š", investigator: "πŸ”", executive: "🎯",
storyteller: "πŸ“–", auditor: "βœ…", strategist: "🧭",
};
const DENSITY_GAP: Record<string, string> = {
compact: "gap-3", comfortable: "gap-4", spacious: "gap-5",
};
export function DynamicReport({ plan }: { plan: UIPlan }) {
const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
const [hiddenCards, setHiddenCards] = useState<Set<string>>(new Set());
const cellById = useMemo(
() => new Map(plan.layout.grid.map((g) => [g.component, g])),
[plan.layout.grid]
);
const persona = plan.persona ?? {
name: "analyst", accent: "#F47C2C", mood: "neutral", density: "comfortable",
};
const accent = persona.accent ?? "#F47C2C";
const mood = MOOD_TINTS[persona.mood ?? "neutral"] ?? MOOD_TINTS.neutral;
const gap = DENSITY_GAP[persona.density ?? "comfortable"] ?? DENSITY_GAP.comfortable;
const icon = PERSONA_ICONS[persona.name ?? "analyst"] ?? "πŸ“Š";
// Group components by semantic role
const { kpis, charts, tables, metas } = useMemo(() => {
const k: ChartComponent[] = [];
const c: ChartComponent[] = [];
const t: ChartComponent[] = [];
const m: ChartComponent[] = [];
for (const comp of plan.components) {
if (hiddenCards.has(comp.id)) continue;
if (comp.type === "kpi") k.push(comp);
else if (comp.type === "data-table") t.push(comp);
else if (comp.type === "raw-sql" || comp.type === "callout" || comp.type === "narrative") m.push(comp);
else c.push(comp);
}
return { kpis: k, charts: c, tables: t, metas: m };
}, [plan.components, hiddenCards]);
const visibleCount = plan.components.length - hiddenCards.size;
const toggleExpand = (id: string) => {
setExpandedCards((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
const toggleHide = (id: string) => {
setHiddenCards((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
};
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px] overflow-x-hidden">
<div className="min-w-0 space-y-6">
{/* ── Narrative Hero ───────────────────────────────────────── */}
{plan.narrative && plan.narrative.trim().length > 20 ? (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05, duration: 0.4 }}
className={`rounded-2xl border bg-gradient-to-br ${mood} p-6 shadow-mb-card backdrop-blur-sm`}
style={{ borderColor: `${accent}25` }}
>
<div className="mb-4 flex items-center gap-3">
<div
className="flex h-8 w-8 items-center justify-center rounded-lg text-sm shadow-sm"
style={{ backgroundColor: `${accent}15` }}
>
{icon}
</div>
<div className="flex items-center gap-2">
<span
className="rounded-full px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-[0.15em] text-white shadow-sm"
style={{ backgroundColor: accent }}
>
{persona.name}
</span>
<span className="text-[10px] uppercase tracking-[0.12em] text-mb-slate/45 font-medium">
{persona.mood} Β· {visibleCount} panels
</span>
</div>
<div className="ml-auto flex h-1 flex-1 max-w-24 rounded-full overflow-hidden bg-mb-amber-100/60">
<div className="rounded-full" style={{ width: "100%", backgroundImage: `linear-gradient(90deg, ${accent}, #FFC72C)` }} />
</div>
</div>
<div className="font-serif text-[17px] leading-[1.7] text-mb-ink/90">
{renderRichText(plan.narrative, accent)}
</div>
{plan.components.some((c) => c.why) && (
<div className="mt-4 flex flex-wrap gap-1.5">
{plan.components
.filter((c) => c.why)
.slice(0, 4)
.map((c) => (
<span key={c.id} className="rounded-full bg-white/80 px-2.5 py-1 text-[10px] text-mb-slate/70 border border-mb-amber-200/50 shadow-sm">
{c.why!.slice(0, 60)}{c.why!.length > 60 ? "…" : ""}
</span>
))}
</div>
)}
</motion.div>
) : null}
{/* ── KPI Strip ─────────────────────────────────────────── */}
{kpis.length > 0 && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.1 }}
className={`grid gap-3 ${
kpis.length === 1 ? "grid-cols-1" :
kpis.length === 2 ? "grid-cols-1 sm:grid-cols-2" :
kpis.length === 3 ? "grid-cols-1 sm:grid-cols-3" :
"grid-cols-2 sm:grid-cols-2 lg:grid-cols-4"
}`}
>
{kpis.slice(0, 8).map((c, i) => (
<motion.div key={c.id} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 + i * 0.04 }}>
<ChartRenderer component={c} accent={accent} />
</motion.div>
))}
</motion.div>
)}
{/* ── Charts Grid ───────────────────────────────────────── */}
<div className={`grid grid-cols-12 ${gap}`}>
<AnimatePresence mode="popLayout">
{charts.map((c, i) => {
const cell = cellById.get(c.id);
const isExpanded = expandedCards.has(c.id);
const w = isExpanded ? 12 : (cell?.w ?? 6);
return (
<motion.div
key={c.id}
layout
initial={{ opacity: 0, y: 18, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.92 }}
transition={{ delay: 0.12 + i * 0.06, duration: 0.45, type: "spring", stiffness: 260, damping: 24, layout: { duration: 0.3 } }}
className="group/card rounded-2xl border bg-white/95 shadow-mb-card backdrop-blur-sm transition-all duration-300 hover:shadow-lg hover:shadow-mb-orange/8"
style={{
gridColumn: `span ${w} / span ${w}`,
minHeight: (cell?.h ?? 5) * 64,
borderColor: `${accent}18`,
}}
>
<div className="p-4">
<CardHeader
component={c}
accent={accent}
expanded={isExpanded}
onToggleExpand={() => toggleExpand(c.id)}
onToggleHide={() => toggleHide(c.id)}
/>
<ChartRenderer component={c} accent={accent} />
</div>
</motion.div>
);
})}
</AnimatePresence>
</div>
{/* ── Callouts / Metas ──────────────────────────────────── */}
{metas.length > 0 && (
<div className="space-y-3">
{metas.map((c, i) => (
<motion.div
key={c.id}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 + i * 0.03 }}
>
<CardHeader component={c} accent={accent} />
<ChartRenderer component={c} accent={accent} />
</motion.div>
))}
</div>
)}
{/* ── Data Tables ───────────────────────────────────────── */}
{tables.length > 0 && (
<div className="space-y-4">
<div className="flex items-center gap-2 text-[11px] uppercase tracking-[0.12em] text-mb-slate/50 font-semibold">
<BarChart3 className="h-3.5 w-3.5" />
Source Data
</div>
{tables.map((c, i) => (
<motion.div
key={c.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2 + i * 0.03 }}
className="group/card rounded-2xl border bg-white/90 p-4 shadow-sm"
style={{ borderColor: `${accent}15` }}
>
<CardHeader component={c} accent={accent} />
<ChartRenderer component={c} accent={accent} />
</motion.div>
))}
</div>
)}
{/* ── Hidden panels recovery ────────────────────────────── */}
{hiddenCards.size > 0 && (
<div className="flex items-center gap-2 text-[11px] text-mb-slate/50">
<EyeOff className="h-3 w-3" />
{hiddenCards.size} panel{hiddenCards.size > 1 ? "s" : ""} hidden Β·{" "}
<button onClick={() => setHiddenCards(new Set())} className="underline hover:text-mb-amber-700">
Show all
</button>
</div>
)}
</div>
<CitationsRail citations={plan.citations} accent={accent} />
</div>
);
}
function CardHeader({
component, accent, expanded, onToggleExpand, onToggleHide,
}: {
component: ChartComponent;
accent: string;
expanded?: boolean;
onToggleExpand?: () => void;
onToggleHide?: () => void;
}) {
const [copied, setCopied] = useState(false);
const data = component.data;
const isTabular = Array.isArray(data) && data.length > 0 && typeof data[0] === "object";
const isSql = component.type === "raw-sql";
async function onCopySql() {
const sql = (data as any)?.sql ?? "";
if (!sql) return;
if (await copyToClipboard(sql)) {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}
}
function onDownloadCsv() {
if (!isTabular) return;
const csv = rowsToCsv(data as any[]);
if (!csv) return;
downloadFile(safeFilename(component.title || component.id, "csv"), csv);
}
if (!component.title && !component.subtitle && !component.why && !isTabular && !isSql) {
return null;
}
return (
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
{component.title ? (
<h3 className="truncate text-[13px] font-semibold text-mb-ink" title={component.title}>
<span
className="mr-1.5 inline-block h-1.5 w-1.5 rounded-full align-middle"
style={{ backgroundColor: accent }}
/>
{component.title}
</h3>
) : null}
{component.subtitle ? (
<p className="mt-0.5 truncate text-[11px] text-mb-slate/60">{component.subtitle}</p>
) : null}
</div>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity duration-200 group-hover/card:opacity-100">
{component.why && (
<span title={component.why} className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700 cursor-help">
<Info className="h-3.5 w-3.5" />
</span>
)}
{onToggleExpand && (
<button onClick={onToggleExpand} title={expanded ? "Collapse" : "Expand"} className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700">
<Maximize2 className="h-3.5 w-3.5" />
</button>
)}
{onToggleHide && (
<button onClick={onToggleHide} title="Hide panel" className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700">
<EyeOff className="h-3 w-3" />
</button>
)}
{isSql && (
<button onClick={onCopySql} title="Copy SQL" className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700">
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5" />}
</button>
)}
{isTabular && (
<button onClick={onDownloadCsv} title="Download CSV" className="rounded-md p-1 text-mb-slate/50 hover:bg-mb-amber-50 hover:text-mb-amber-700">
<Download className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
);
}