reachy_mini_minder / frontend /src /components /ReportsPanel.tsx
Boopster's picture
feat: Enhance onboarding flow with progressive medication setup and new LangGraph UI component, alongside dependency updates.
cd73917
"use client";
import React, { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import {
FileText,
BarChart3,
X,
ChevronRight,
Sparkles,
Clock,
Calendar,
Plus,
} from "lucide-react";
import { useLangGraph } from "@/hooks/useLangGraph";
import { renderComponent } from "@/registry";
interface UIEvent {
id: string;
name: string;
props: Record<string, unknown>;
status: "loading" | "streaming" | "complete" | "error";
}
interface SavedReport {
id: number;
report_type: string;
title: string;
content: string;
metadata?: Record<string, unknown>;
created_at: string;
}
interface ReportsPanelProps {
isOpen?: boolean;
onToggle?: () => void;
}
const API_BASE =
process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000/api/stream";
function formatReportDate(dateStr: string): string {
try {
const d = new Date(dateStr + "Z");
return d.toLocaleDateString("en-GB", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return dateStr;
}
}
export function ReportsPanel({
isOpen: controlledIsOpen,
onToggle,
}: ReportsPanelProps = {}) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen =
controlledIsOpen !== undefined ? controlledIsOpen : internalIsOpen;
const toggleOpen = onToggle || (() => setInternalIsOpen((prev) => !prev));
const { submit, uiEvents, isProcessing, clear } = useLangGraph<UIEvent>();
// Saved reports from API
const [savedReports, setSavedReports] = useState<SavedReport[]>([]);
const [viewingReport, setViewingReport] = useState<SavedReport | null>(null);
const [loadingReports, setLoadingReports] = useState(false);
const reportRef = useRef<HTMLDivElement>(null);
// Fetch saved reports when modal opens
useEffect(() => {
if (!isOpen) return;
setLoadingReports(true);
fetch(`${API_BASE}/reports`)
.then((r) => r.json())
.then((data: SavedReport[]) => {
if (Array.isArray(data)) setSavedReports(data);
})
.catch(() => {})
.finally(() => setLoadingReports(false));
}, [isOpen]);
// Auto-scroll to generated report when it appears
useEffect(() => {
if (uiEvents.length > 0 && reportRef.current) {
reportRef.current.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, [uiEvents.length]);
// When report generation completes, refresh the saved list
const lastEvent = uiEvents[uiEvents.length - 1];
const isComplete =
lastEvent &&
!isProcessing &&
(lastEvent.props as Record<string, unknown>)?.status === "complete";
useEffect(() => {
if (isComplete) {
fetch(`${API_BASE}/reports`)
.then((r) => r.json())
.then((data: SavedReport[]) => {
if (Array.isArray(data)) setSavedReports(data);
})
.catch(() => {});
}
}, [isComplete]);
if (!isOpen) {
return (
<button
onClick={() => toggleOpen()}
className="flex flex-col items-center gap-1 p-2 text-gray-400 hover:text-white transition-all"
title="My Reports"
>
<BarChart3 className="w-5 h-5" />
<span className="text-[10px]">Reports</span>
</button>
);
}
// Are we viewing a past report (not a live-generated one)?
const showingSavedReport = viewingReport !== null && uiEvents.length === 0;
const showingGenerated = uiEvents.length > 0;
return (
<>
<button onClick={() => toggleOpen()} className="btn btn-secondary p-2">
<BarChart3 className="w-5 h-5" />
</button>
{createPortal(
<div
className="modal-overlay"
onClick={(e) => {
if (e.target === e.currentTarget) toggleOpen();
}}
>
<div className="modal-fullscreen p-0">
{/* Header */}
<div className="px-6 py-4 border-b border-surface-overlay flex items-center justify-between bg-surface-subtle"
style={{ flexShrink: 0 }}
>
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center shadow-md"
style={{
background: "var(--color-cta)",
boxShadow: "0 2px 8px rgba(179, 156, 208, 0.3)",
}}
>
<FileText className="w-5 h-5" style={{ color: "#1a1a1a" }} />
</div>
<div>
<h3
style={{
fontSize: 20,
fontWeight: 700,
color: "var(--color-text-primary)",
margin: 0,
letterSpacing: "-0.01em",
}}
>
My Reports
</h3>
<p
style={{
fontSize: 11,
color: "var(--color-text-muted)",
textTransform: "uppercase",
letterSpacing: "0.1em",
fontWeight: 500,
margin: 0,
}}
>
Clinical Summaries & Trends
</p>
</div>
</div>
<div className="flex items-center gap-3">
{/* Back button when viewing a single report */}
{(showingSavedReport || showingGenerated) && (
<button
onClick={() => {
setViewingReport(null);
clear();
}}
className="px-3 py-1.5 text-xs font-bold text-cta border border-cta/30 rounded-lg hover:bg-cta/10 transition-colors"
>
← All Reports
</button>
)}
<button
onClick={() => toggleOpen()}
className="btn btn-ghost p-1.5 hover:bg-surface-elevated rounded-lg transition-colors"
>
<X className="w-5 h-5 text-secondary" />
</button>
</div>
</div>
{/* Content */}
<div
className="flex-1 overflow-y-auto custom-scrollbar"
style={{
background: "rgba(18, 18, 18, 0.5)",
minHeight: 0,
}}
>
{/* === STATE: Processing / Spinner === */}
{isProcessing && uiEvents.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-center gap-6">
<div className="relative">
<div className="w-20 h-20 border-4 border-accent-cyan/10 border-t-accent-cyan rounded-full animate-spin shadow-lg shadow-accent-cyan/10" />
<Sparkles className="absolute inset-0 m-auto w-8 h-8 text-accent-cyan animate-pulse" />
</div>
<div className="space-y-2">
<h3 className="text-primary font-bold tracking-tight text-lg">
AI Analyst Working
</h3>
<p className="text-[10px] text-muted font-bold uppercase tracking-widest">
Correlating clinical data...
</p>
</div>
</div>
)}
{/* === STATE: Viewing a generated report (live) === */}
{showingGenerated && (
<div className="p-6 max-w-3xl mx-auto" ref={reportRef}>
<div className="space-y-6">
{uiEvents.map((ui) => (
<div
key={ui.id}
className="animate-in fade-in slide-in-from-bottom-8 duration-700"
>
{renderComponent(ui.name, ui.props)}
</div>
))}
</div>
</div>
)}
{/* === STATE: Viewing a saved report (from history) === */}
{showingSavedReport && (
<div className="p-6 max-w-3xl mx-auto">
{renderComponent("ReportPreview", {
id: viewingReport!.id,
title: viewingReport!.title,
content: viewingReport!.content,
status: "complete",
doctorName: (viewingReport!.metadata as Record<string, string>)
?.doctorName,
doctorEmail: (viewingReport!.metadata as Record<string, string>)
?.doctorEmail,
})}
</div>
)}
{/* === STATE: Reports list (default view) === */}
{!isProcessing && !showingGenerated && !showingSavedReport && (
<div className="p-6 space-y-8">
{/* Action cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-w-3xl mx-auto animate-in fade-in slide-in-from-bottom-4 duration-500">
<button
onClick={() => submit({ type: "report" })}
className="group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-cta/40 hover:shadow-2xl hover:shadow-cta/5 overflow-hidden text-left"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-cta/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
<div className="w-14 h-14 bg-cta/10 text-cta rounded-xl flex items-center justify-center shrink-0 border border-cta/20 group-hover:scale-110 group-hover:bg-cta group-hover:text-black transition-all shadow-lg shadow-cta/10">
<Plus className="w-7 h-7" />
</div>
<div className="flex flex-col gap-1 pr-4">
<h3 className="text-base font-bold text-primary">
New Report
</h3>
<p className="text-sm text-secondary leading-relaxed">
Generate a clinical summary for your next appointment.
</p>
<div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-cta group-hover:translate-x-1 transition-transform">
Start Generation{" "}
<ChevronRight className="w-3 h-3 ml-1" />
</div>
</div>
</button>
<button
onClick={() => submit({ type: "trends" })}
className="group relative flex flex-row items-center gap-6 p-6 bg-surface-subtle/50 border border-surface-overlay rounded-2xl transition-all hover:bg-surface-elevated hover:border-accent-cyan/40 hover:shadow-2xl hover:shadow-accent-cyan/5 overflow-hidden text-left"
>
<div className="absolute top-0 right-0 w-32 h-32 bg-accent-cyan/5 rounded-full -mr-16 -mt-16 transition-transform group-hover:scale-150 duration-700 blur-2xl" />
<div className="w-14 h-14 bg-accent-cyan/10 text-accent-cyan rounded-xl flex items-center justify-center shrink-0 border border-accent-cyan/20 group-hover:scale-110 group-hover:bg-accent-cyan group-hover:text-black transition-all shadow-lg shadow-accent-cyan/10">
<BarChart3 className="w-7 h-7" />
</div>
<div className="flex flex-col gap-1 pr-4">
<h3 className="text-base font-bold text-primary">
Analyse Trends
</h3>
<p className="text-sm text-secondary leading-relaxed">
Uncover hidden patterns and triggers in your headache
diary.
</p>
<div className="mt-2 flex items-center text-[10px] font-black uppercase tracking-widest text-accent-cyan group-hover:translate-x-1 transition-transform">
View Insights{" "}
<ChevronRight className="w-3 h-3 ml-1" />
</div>
</div>
</button>
</div>
{/* Past reports list */}
{loadingReports && (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-cta/20 border-t-cta rounded-full animate-spin" />
</div>
)}
{!loadingReports && savedReports.length > 0 && (
<div className="max-w-3xl mx-auto">
<div className="flex items-center gap-2 mb-4">
<Clock
className="w-4 h-4"
style={{ color: "var(--color-text-muted)" }}
/>
<span
style={{
fontSize: 12,
fontWeight: 900,
textTransform: "uppercase",
letterSpacing: "0.15em",
color: "var(--color-text-muted)",
}}
>
Report History
</span>
</div>
<div className="space-y-2">
{savedReports.map((report) => (
<button
key={report.id}
onClick={() => setViewingReport(report)}
className="w-full flex items-center gap-4 p-4 bg-surface-subtle/40 border border-surface-overlay rounded-xl hover:bg-surface-elevated hover:border-cta/20 transition-all group text-left"
>
<div className="w-10 h-10 rounded-lg bg-cta/10 text-cta flex items-center justify-center shrink-0 border border-cta/15 group-hover:bg-cta/20 transition-colors">
<FileText className="w-5 h-5" />
</div>
<div className="flex-1 min-w-0">
<h4 className="text-sm font-bold text-primary truncate">
{report.title}
</h4>
<div className="flex items-center gap-2 mt-0.5">
<Calendar
className="w-3 h-3"
style={{
color: "var(--color-text-muted)",
}}
/>
<span
style={{
fontSize: 11,
color: "var(--color-text-muted)",
fontWeight: 500,
}}
>
{formatReportDate(report.created_at)}
</span>
<span className="pill pill-lavender text-[9px] font-bold">
{report.report_type}
</span>
</div>
</div>
<ChevronRight
className="w-4 h-4 text-muted group-hover:text-cta group-hover:translate-x-1 transition-all"
/>
</button>
))}
</div>
</div>
)}
{!loadingReports && savedReports.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center gap-4">
<div className="w-16 h-16 rounded-2xl bg-surface-subtle border border-surface-overlay flex items-center justify-center">
<FileText
className="w-8 h-8"
style={{ color: "var(--color-text-muted)", opacity: 0.4 }}
/>
</div>
<div>
<p
style={{
fontSize: 14,
fontWeight: 600,
color: "var(--color-text-secondary)",
}}
>
No reports yet
</p>
<p
style={{
fontSize: 12,
color: "var(--color-text-muted)",
marginTop: 4,
}}
>
Generate your first clinical report above.
</p>
</div>
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div
className="px-8 py-4 bg-surface-subtle border-t border-surface-overlay flex items-center justify-between"
style={{ flexShrink: 0 }}
>
<div className="flex items-center gap-4">
<div className="px-2 py-1 rounded bg-accent-cyan/10 border border-accent-cyan/20 text-accent-cyan text-[9px] font-black uppercase tracking-widest">
Data Policy
</div>
<p className="text-[11px] text-muted font-medium">
Analysis based on last 30 days of local records in{" "}
<strong>mini_minder.db</strong>.
</p>
</div>
<Sparkles className="w-4 h-4 text-accent-cyan/30" />
</div>
</div>
</div>,
document.body
)}
</>
);
}