openclaw/apps/web/app/components/charts/report-card.tsx
kumarabhirup cd98f0acba
ReportCard: expand report inline instead of navigating away
Open button now toggles an expanded view showing all panels in a full
grid layout with animated transitions. Pin button already saves to
workspace /reports. Lazy-loads additional panel data on first expand
and adds a refresh button in expanded mode.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-16 23:04:28 -08:00

465 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChartPanel } from "./chart-panel";
import type { ReportConfig, PanelConfig } from "./types";
type ReportCardProps = {
config: ReportConfig;
};
// --- Icons ---
function ChartBarIcon() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="20" y2="10" />
<line x1="18" x2="18" y1="20" y2="4" />
<line x1="6" x2="6" y1="20" y2="14" />
</svg>
);
}
function ExpandIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" x2="14" y1="3" y2="10" />
<line x1="3" x2="10" y1="21" y2="14" />
</svg>
);
}
function CollapseIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="4 14 10 14 10 20" />
<polyline points="20 10 14 10 14 4" />
<line x1="14" x2="21" y1="10" y2="3" />
<line x1="3" x2="10" y1="21" y2="14" />
</svg>
);
}
function PinIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" x2="12" y1="17" y2="22" />
<path d="M5 17h14v-1.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1v4.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24Z" />
</svg>
);
}
function RefreshIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
);
}
// --- Panel data state ---
type PanelData = {
rows: Record<string, unknown>[];
loading: boolean;
error?: string;
};
// --- Grid size helpers ---
function panelColSpan(size?: string): string {
switch (size) {
case "full":
return "col-span-6";
case "third":
return "col-span-2";
case "half":
default:
return "col-span-3";
}
}
// --- Main ReportCard ---
export function ReportCard({ config }: ReportCardProps) {
const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
const [pinning, setPinning] = useState(false);
const [pinned, setPinned] = useState(false);
const [expanded, setExpanded] = useState(false);
// In compact mode show at most 2 panels; expanded shows all
const visiblePanels = expanded ? config.panels : config.panels.slice(0, 2);
const hasMore = config.panels.length > 2;
// Execute panel SQL queries
const executePanels = useCallback(async (panels: PanelConfig[]) => {
const initial: Record<string, PanelData> = {};
for (const panel of panels) {
initial[panel.id] = { rows: [], loading: true };
}
setPanelData((prev) => ({ ...prev, ...initial }));
await Promise.all(
panels.map(async (panel) => {
try {
const res = await fetch("/api/workspace/reports/execute", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sql: panel.sql }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
setPanelData((prev) => ({
...prev,
[panel.id]: { rows: [], loading: false, error: data.error || `HTTP ${res.status}` },
}));
return;
}
const data = await res.json();
setPanelData((prev) => ({
...prev,
[panel.id]: { rows: data.rows ?? [], loading: false },
}));
} catch (err) {
setPanelData((prev) => ({
...prev,
[panel.id]: { rows: [], loading: false, error: err instanceof Error ? err.message : "Failed" },
}));
}
}),
);
}, []);
// Load initial compact panels
useEffect(() => {
void executePanels(config.panels.slice(0, 2));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// When expanding, fetch any panels not yet loaded
const handleToggleExpand = useCallback(() => {
setExpanded((prev) => {
const next = !prev;
if (next && hasMore) {
const unloaded = config.panels.filter((p) => !panelData[p.id]);
if (unloaded.length > 0) {
void executePanels(unloaded);
}
}
return next;
});
}, [hasMore, config.panels, panelData, executePanels]);
// Refresh all visible panels
const handleRefresh = useCallback(() => {
void executePanels(expanded ? config.panels : config.panels.slice(0, 2));
}, [expanded, config.panels, executePanels]);
// Pin report to workspace /reports directory
const handlePin = async () => {
setPinning(true);
try {
const slug = config.title
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 40);
const filename = `${slug}.report.json`;
await fetch("/api/workspace/file", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: `reports/${filename}`,
content: JSON.stringify(config, null, 2),
}),
});
setPinned(true);
} catch {
// silently fail
} finally {
setPinning(false);
}
};
return (
<div
className="rounded-xl overflow-hidden my-2 transition-all duration-200"
style={{
background: "var(--color-bg)",
border: "1px solid var(--color-border)",
maxWidth: "100%",
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-3 py-2 border-b"
style={{ borderColor: "var(--color-border)" }}
>
<div className="flex items-center gap-2 min-w-0">
<span style={{ color: "#22c55e" }}>
<ChartBarIcon />
</span>
<span
className="text-sm font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{config.title}
</span>
<span
className="text-[10px] px-1.5 py-0.5 rounded-full flex-shrink-0"
style={{
background: "rgba(34, 197, 94, 0.1)",
color: "#22c55e",
}}
>
{config.panels.length} chart{config.panels.length !== 1 ? "s" : ""}
</span>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{expanded && (
<button
type="button"
onClick={handleRefresh}
className="p-1 rounded-md transition-colors cursor-pointer"
style={{ color: "var(--color-text-muted)" }}
title="Refresh data"
>
<RefreshIcon />
</button>
)}
{!pinned ? (
<button
type="button"
onClick={handlePin}
disabled={pinning}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer disabled:opacity-40"
style={{
color: "var(--color-text-muted)",
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
title="Save to workspace /reports"
>
<PinIcon />
{pinning ? "Saving..." : "Pin"}
</button>
) : (
<span
className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-md"
style={{ color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }}
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
Pinned
</span>
)}
<button
type="button"
onClick={handleToggleExpand}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer"
style={{
color: expanded ? "var(--color-text)" : "var(--color-accent)",
background: expanded ? "var(--color-surface-hover)" : "var(--color-accent-light)",
}}
title={expanded ? "Collapse report" : "Expand full report"}
>
{expanded ? <CollapseIcon /> : <ExpandIcon />}
{expanded ? "Collapse" : "Open"}
</button>
</div>
</div>
{/* Description */}
{config.description && (
<div className="px-3 py-1.5">
<p className="text-[11px]" style={{ color: "var(--color-text-muted)" }}>
{config.description}
</p>
</div>
)}
<AnimatePresence mode="wait" initial={false}>
{expanded ? (
/* ── Expanded: full grid with all panels ── */
<motion.div
key="expanded"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.25, ease: "easeInOut" }}
className="overflow-hidden"
>
<div className="grid grid-cols-6 gap-3 p-3">
{config.panels.map((panel) => (
<ExpandedPanelCard
key={panel.id}
panel={panel}
data={panelData[panel.id]}
/>
))}
</div>
</motion.div>
) : (
/* ── Compact: max 2 panels ── */
<motion.div
key="compact"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
>
<div className={`grid gap-2 p-2 ${visiblePanels.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}>
{visiblePanels.map((panel) => (
<CompactPanelCard
key={panel.id}
panel={panel}
data={panelData[panel.id]}
/>
))}
</div>
{/* More panels indicator */}
{hasMore && (
<button
type="button"
onClick={handleToggleExpand}
className="w-full px-3 py-1.5 text-center border-t cursor-pointer transition-colors hover:opacity-80"
style={{ borderColor: "var(--color-border)" }}
>
<span className="text-[10px]" style={{ color: "var(--color-accent)" }}>
+{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""} click to expand
</span>
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// --- Compact panel card for inline rendering ---
function CompactPanelCard({
panel,
data,
}: {
panel: PanelConfig;
data?: PanelData;
}) {
return (
<div
className="rounded-lg overflow-hidden"
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
>
<div className="px-2.5 py-1.5">
<h4
className="text-[11px] font-medium truncate"
style={{ color: "var(--color-text)" }}
>
{panel.title}
</h4>
</div>
<div className="px-1 pb-1">
{data?.loading ? (
<div className="flex items-center justify-center" style={{ height: 200 }}>
<div
className="w-4 h-4 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
) : data?.error ? (
<div className="flex items-center justify-center" style={{ height: 200 }}>
<p className="text-[10px]" style={{ color: "#f87171" }}>
{data.error}
</p>
</div>
) : (
<ChartPanel config={panel} data={data?.rows ?? []} compact />
)}
</div>
</div>
);
}
// --- Expanded panel card for full report view ---
function ExpandedPanelCard({
panel,
data,
}: {
panel: PanelConfig;
data?: PanelData;
}) {
const colSpan = panelColSpan(panel.size);
return (
<div
className={`${colSpan} rounded-xl overflow-hidden`}
style={{
background: "var(--color-surface)",
border: "1px solid var(--color-border)",
}}
>
<div className="px-3 py-2 flex items-center justify-between">
<h4
className="text-xs font-medium"
style={{ color: "var(--color-text)" }}
>
{panel.title}
</h4>
{data && !data.loading && !data.error && (
<span
className="text-[10px] px-1.5 py-0.5 rounded"
style={{ color: "var(--color-text-muted)" }}
>
{data.rows.length} rows
</span>
)}
</div>
<div className="px-1.5 pb-2">
{data?.loading ? (
<div className="flex items-center justify-center" style={{ height: 280 }}>
<div
className="w-4 h-4 border-2 rounded-full animate-spin"
style={{
borderColor: "var(--color-border)",
borderTopColor: "var(--color-accent)",
}}
/>
</div>
) : data?.error ? (
<div className="flex flex-col items-center justify-center gap-1.5" style={{ height: 280 }}>
<p className="text-[10px]" style={{ color: "#f87171" }}>
Query error
</p>
<p
className="text-[10px] px-2 py-1 rounded max-w-xs text-center"
style={{ background: "rgba(248, 113, 113, 0.1)", color: "#f87171" }}
>
{data.error}
</p>
</div>
) : (
<ChartPanel config={panel} data={data?.rows ?? []} />
)}
</div>
</div>
);
}