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>
This commit is contained in:
kumarabhirup 2026-02-16 23:04:28 -08:00
parent 7c2e056437
commit cd98f0acba
No known key found for this signature in database
GPG Key ID: DB7CA2289CAB0167

View File

@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { ChartPanel } from "./chart-panel"; import { ChartPanel } from "./chart-panel";
import type { ReportConfig, PanelConfig } from "./types"; import type { ReportConfig, PanelConfig } from "./types";
@ -20,12 +21,24 @@ function ChartBarIcon() {
); );
} }
function ExternalLinkIcon() { function ExpandIcon() {
return ( return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" /> <polyline points="15 3 21 3 21 9" />
<line x1="10" x2="21" y1="14" y2="3" /> <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> </svg>
); );
} }
@ -39,6 +52,17 @@ function PinIcon() {
); );
} }
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 --- // --- Panel data state ---
type PanelData = { type PanelData = {
@ -47,26 +71,42 @@ type PanelData = {
error?: string; 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 --- // --- Main ReportCard ---
export function ReportCard({ config }: ReportCardProps) { export function ReportCard({ config }: ReportCardProps) {
const [panelData, setPanelData] = useState<Record<string, PanelData>>({}); const [panelData, setPanelData] = useState<Record<string, PanelData>>({});
const [pinning, setPinning] = useState(false); const [pinning, setPinning] = useState(false);
const [pinned, setPinned] = useState(false); const [pinned, setPinned] = useState(false);
const [expanded, setExpanded] = useState(false);
// Show at most 2 panels inline // In compact mode show at most 2 panels; expanded shows all
const visiblePanels = config.panels.slice(0, 2); const visiblePanels = expanded ? config.panels : config.panels.slice(0, 2);
const hasMore = config.panels.length > 2;
// Execute panel SQL queries // Execute panel SQL queries
const executePanels = useCallback(async () => { const executePanels = useCallback(async (panels: PanelConfig[]) => {
const initial: Record<string, PanelData> = {}; const initial: Record<string, PanelData> = {};
for (const panel of visiblePanels) { for (const panel of panels) {
initial[panel.id] = { rows: [], loading: true }; initial[panel.id] = { rows: [], loading: true };
} }
setPanelData(initial); setPanelData((prev) => ({ ...prev, ...initial }));
await Promise.all( await Promise.all(
visiblePanels.map(async (panel) => { panels.map(async (panel) => {
try { try {
const res = await fetch("/api/workspace/reports/execute", { const res = await fetch("/api/workspace/reports/execute", {
method: "POST", method: "POST",
@ -94,14 +134,34 @@ export function ReportCard({ config }: ReportCardProps) {
} }
}), }),
); );
}, [visiblePanels]); }, []);
// Load initial compact panels
useEffect(() => { useEffect(() => {
void executePanels(); void executePanels(config.panels.slice(0, 2));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// Pin report to workspace filesystem // 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 () => { const handlePin = async () => {
setPinning(true); setPinning(true);
try { try {
@ -130,7 +190,7 @@ export function ReportCard({ config }: ReportCardProps) {
return ( return (
<div <div
className="rounded-xl overflow-hidden my-2" className="rounded-xl overflow-hidden my-2 transition-all duration-200"
style={{ style={{
background: "var(--color-bg)", background: "var(--color-bg)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
@ -164,7 +224,18 @@ export function ReportCard({ config }: ReportCardProps) {
</div> </div>
<div className="flex items-center gap-1.5 flex-shrink-0"> <div className="flex items-center gap-1.5 flex-shrink-0">
{!pinned && ( {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 <button
type="button" type="button"
onClick={handlePin} onClick={handlePin}
@ -175,31 +246,35 @@ export function ReportCard({ config }: ReportCardProps) {
background: "var(--color-surface)", background: "var(--color-surface)",
border: "1px solid var(--color-border)", border: "1px solid var(--color-border)",
}} }}
title="Save to workspace" title="Save to workspace /reports"
> >
<PinIcon /> <PinIcon />
{pinning ? "Saving..." : "Pin"} {pinning ? "Saving..." : "Pin"}
</button> </button>
)} ) : (
{pinned && (
<span <span
className="text-[10px] px-2 py-1 rounded-md" className="flex items-center gap-1 text-[10px] px-2 py-1 rounded-md"
style={{ color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }} style={{ color: "#22c55e", background: "rgba(34, 197, 94, 0.1)" }}
> >
Saved <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> </span>
)} )}
<a <button
href="/workspace" type="button"
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors" onClick={handleToggleExpand}
className="flex items-center gap-1 px-2 py-1 rounded-md text-[10px] transition-colors cursor-pointer"
style={{ style={{
color: "var(--color-accent)", color: expanded ? "var(--color-text)" : "var(--color-accent)",
background: "var(--color-accent-light)", background: expanded ? "var(--color-surface-hover)" : "var(--color-accent-light)",
}} }}
title={expanded ? "Collapse report" : "Expand full report"}
> >
<ExternalLinkIcon /> {expanded ? <CollapseIcon /> : <ExpandIcon />}
Open {expanded ? "Collapse" : "Open"}
</a> </button>
</div> </div>
</div> </div>
@ -212,28 +287,62 @@ export function ReportCard({ config }: ReportCardProps) {
</div> </div>
)} )}
{/* Panels (compact mode) */} <AnimatePresence mode="wait" initial={false}>
<div className={`grid gap-2 p-2 ${visiblePanels.length > 1 ? "grid-cols-2" : "grid-cols-1"}`}> {expanded ? (
{visiblePanels.map((panel) => ( /* ── Expanded: full grid with all panels ── */
<CompactPanelCard <motion.div
key={panel.id} key="expanded"
panel={panel} initial={{ opacity: 0, height: 0 }}
data={panelData[panel.id]} animate={{ opacity: 1, height: "auto" }}
/> exit={{ opacity: 0, height: 0 }}
))} transition={{ duration: 0.25, ease: "easeInOut" }}
</div> 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 */} {/* More panels indicator */}
{config.panels.length > 2 && ( {hasMore && (
<div <button
className="px-3 py-1.5 text-center border-t" type="button"
style={{ borderColor: "var(--color-border)" }} onClick={handleToggleExpand}
> className="w-full px-3 py-1.5 text-center border-t cursor-pointer transition-colors hover:opacity-80"
<span className="text-[10px]" style={{ color: "var(--color-text-muted)" }}> style={{ borderColor: "var(--color-border)" }}
+{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""} >
</span> <span className="text-[10px]" style={{ color: "var(--color-accent)" }}>
</div> +{config.panels.length - 2} more chart{config.panels.length - 2 !== 1 ? "s" : ""} click to expand
)} </span>
</button>
)}
</motion.div>
)}
</AnimatePresence>
</div> </div>
); );
} }
@ -287,3 +396,69 @@ function CompactPanelCard({
</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>
);
}