Spaces:
Running
Running
| "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 | |
| )} | |
| </> | |
| ); | |
| } | |