"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 = { 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 = { analyst: "๐Ÿ“Š", investigator: "๐Ÿ”", executive: "๐ŸŽฏ", storyteller: "๐Ÿ“–", auditor: "โœ…", strategist: "๐Ÿงญ", }; const DENSITY_GAP: Record = { compact: "gap-3", comfortable: "gap-4", spacious: "gap-5", }; export function DynamicReport({ plan }: { plan: UIPlan }) { const [expandedCards, setExpandedCards] = useState>(new Set()); const [hiddenCards, setHiddenCards] = useState>(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 (
{/* โ”€โ”€ Narrative Hero โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {plan.narrative && plan.narrative.trim().length > 20 ? (
{icon}
{persona.name} {persona.mood} ยท {visibleCount} panels
{renderRichText(plan.narrative, accent)}
{plan.components.some((c) => c.why) && (
{plan.components .filter((c) => c.why) .slice(0, 4) .map((c) => ( {c.why!.slice(0, 60)}{c.why!.length > 60 ? "โ€ฆ" : ""} ))}
)} ) : null} {/* โ”€โ”€ KPI Strip โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {kpis.length > 0 && ( {kpis.slice(0, 8).map((c, i) => ( ))} )} {/* โ”€โ”€ Charts Grid โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */}
{charts.map((c, i) => { const cell = cellById.get(c.id); const isExpanded = expandedCards.has(c.id); const w = isExpanded ? 12 : (cell?.w ?? 6); return (
toggleExpand(c.id)} onToggleHide={() => toggleHide(c.id)} />
); })}
{/* โ”€โ”€ Callouts / Metas โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {metas.length > 0 && (
{metas.map((c, i) => ( ))}
)} {/* โ”€โ”€ Data Tables โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {tables.length > 0 && (
Source Data
{tables.map((c, i) => ( ))}
)} {/* โ”€โ”€ Hidden panels recovery โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */} {hiddenCards.size > 0 && (
{hiddenCards.size} panel{hiddenCards.size > 1 ? "s" : ""} hidden ยท{" "}
)}
); } 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 (
{component.title ? (

{component.title}

) : null} {component.subtitle ? (

{component.subtitle}

) : null}
{component.why && ( )} {onToggleExpand && ( )} {onToggleHide && ( )} {isSql && ( )} {isTabular && ( )}
); }