| import { useEffect, useState } from "react"; |
| import { FileText, Loader2, Maximize2, Minimize2, PanelRightClose } from "lucide-react"; |
| import { |
| AgenticApiError, |
| generateReport, |
| getReportVersion, |
| listReportVersions, |
| type ReportDetail, |
| type ReportSummary, |
| } from "@/services/agenticApi"; |
| import type { Analysis } from "@/services/orchestrationApi"; |
| import { MarkdownContent } from "./MarkdownContent"; |
| import { ReportVersionSelector } from "./ReportVersionSelector"; |
| import { EmptyState, ErrorState } from "./AnalysisStates"; |
| import { formatDateTime } from "./utils"; |
|
|
| interface ReportSidebarProps { |
| analysis: Analysis | null; |
| userId?: string; |
| onCollapse?: () => void; |
| fullscreen?: boolean; |
| onToggleFullscreen?: () => void; |
| } |
|
|
| export function ReportSidebar({ analysis, userId, onCollapse, fullscreen = false, onToggleFullscreen }: ReportSidebarProps) { |
| const [versions, setVersions] = useState<ReportSummary[]>([]); |
| const [selectedVersion, setSelectedVersion] = useState<number | undefined>(); |
| const [detail, setDetail] = useState<ReportDetail | null>(null); |
| const [loadingVersions, setLoadingVersions] = useState(false); |
| const [loadingDetail, setLoadingDetail] = useState(false); |
| const [generating, setGenerating] = useState(false); |
| const [error, setError] = useState<string | null>(null); |
| const [precondition, setPrecondition] = useState<string | null>(null); |
|
|
| const loadVersions = async (analysisId: string, options: { silent?: boolean } = {}) => { |
| setLoadingVersions(true); |
| setError(null); |
| try { |
| const list = await listReportVersions(analysisId); |
| setVersions(list); |
| const latest = [...list].sort((a, b) => b.version - a.version)[0]; |
| setSelectedVersion(latest?.version); |
| } catch (err) { |
| setVersions([]); |
| setSelectedVersion(undefined); |
| const isMissingReportCollection = err instanceof AgenticApiError && err.status === 404; |
| if (!options.silent && !isMissingReportCollection) { |
| setError(err instanceof Error ? err.message : "Failed to load report versions"); |
| } |
| } finally { |
| setLoadingVersions(false); |
| } |
| }; |
|
|
| useEffect(() => { |
| setDetail(null); |
| setPrecondition(null); |
| setVersions([]); |
| setSelectedVersion(undefined); |
| if (analysis?.id) loadVersions(analysis.id, { silent: true }); |
| }, [analysis?.id]); |
|
|
| useEffect(() => { |
| const loadDetail = async () => { |
| if (!analysis?.id || selectedVersion == null) return; |
| setLoadingDetail(true); |
| setError(null); |
| try { |
| setDetail(await getReportVersion(analysis.id, selectedVersion)); |
| } catch (err) { |
| setError(err instanceof Error ? err.message : "Failed to load report"); |
| } finally { |
| setLoadingDetail(false); |
| } |
| }; |
| loadDetail(); |
| }, [selectedVersion]); |
|
|
| const handleGenerate = async () => { |
| if (!analysis?.id || !userId) return; |
| setGenerating(true); |
| setError(null); |
| setPrecondition(null); |
| try { |
| const report = await generateReport(analysis.id, userId); |
| setDetail(report); |
| await loadVersions(analysis.id, { silent: true }); |
| setSelectedVersion(report.version); |
| } catch (err) { |
| if (err instanceof AgenticApiError && err.status === 409) { |
| setPrecondition(err.message); |
| } else { |
| setError(err instanceof Error ? err.message : "Failed to generate report"); |
| } |
| } finally { |
| setGenerating(false); |
| } |
| }; |
|
|
| return ( |
| <section className="space-y-4"> |
| <div className="flex items-center justify-between gap-3"> |
| <div className="flex min-w-0 items-center gap-2"> |
| <FileText className="h-4 w-4 flex-shrink-0 text-slate-600" /> |
| <div className="min-w-0"> |
| <h3 className="text-sm font-semibold text-slate-900">Report</h3> |
| <p className="truncate text-xs text-slate-500">Generate and preview report versions.</p> |
| </div> |
| </div> |
| <div className="flex flex-shrink-0 items-center gap-1.5"> |
| {onToggleFullscreen && ( |
| <button |
| type="button" |
| title={fullscreen ? "Exit fullscreen" : "Expand report to fullscreen"} |
| aria-label={fullscreen ? "Exit fullscreen" : "Expand report to fullscreen"} |
| onClick={onToggleFullscreen} |
| className="rounded-md p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-900" |
| > |
| {fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />} |
| </button> |
| )} |
| {onCollapse && !fullscreen && ( |
| <button |
| type="button" |
| title="Collapse report sidebar" |
| aria-label="Collapse report sidebar" |
| onClick={onCollapse} |
| className="rounded-md p-2 text-slate-500 hover:bg-slate-100 hover:text-slate-900" |
| > |
| <PanelRightClose className="h-4 w-4" /> |
| </button> |
| )} |
| <button |
| type="button" |
| aria-label="Generate report" |
| title="Generate a new report version with the Python report skill" |
| onClick={handleGenerate} |
| disabled={!analysis || !userId || generating} |
| className="inline-flex items-center gap-2 rounded-md bg-slate-900 px-3 py-2 text-xs font-medium text-white hover:bg-slate-800 disabled:cursor-not-allowed disabled:opacity-50" |
| > |
| {generating && <Loader2 className="h-3.5 w-3.5 animate-spin" />} |
| Generate |
| </button> |
| </div> |
| </div> |
| |
| {!analysis ? ( |
| <EmptyState title="Select an analysis" description="Reports are generated per analysis." /> |
| ) : ( |
| <> |
| <ReportVersionSelector versions={versions} selectedVersion={selectedVersion} disabled={loadingVersions || loadingDetail} onSelect={setSelectedVersion} /> |
| {precondition && <p className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{precondition}</p>} |
| {error && <ErrorState title="Report error" message={error} />} |
| {loadingDetail ? ( |
| <div className="flex items-center justify-center gap-2 rounded-lg border border-slate-200 bg-white py-8 text-sm text-slate-500"> |
| <Loader2 className="h-4 w-4 animate-spin" /> Loading report |
| </div> |
| ) : detail ? ( |
| <article className="rounded-lg border border-slate-200 bg-white p-4"> |
| <div className="mb-3 text-xs text-slate-500">v{detail.version} generated {formatDateTime(detail.generated_at)}</div> |
| {detail.rendered_markdown ? ( |
| <MarkdownContent content={detail.rendered_markdown} /> |
| ) : ( |
| <div className="space-y-4 text-sm leading-6 text-slate-700"> |
| {detail.executive_summary && <p>{detail.executive_summary}</p>} |
| {detail.findings && detail.findings.length > 0 && ( |
| <div> |
| <p className="font-medium text-slate-900">Findings</p> |
| <ul className="mt-1 list-disc space-y-1 pl-5"> |
| {detail.findings.map((item, index) => <li key={index}>{item.text}</li>)} |
| </ul> |
| </div> |
| )} |
| {detail.caveats && detail.caveats.length > 0 && ( |
| <div> |
| <p className="font-medium text-slate-900">Caveats</p> |
| <ul className="mt-1 list-disc space-y-1 pl-5"> |
| {detail.caveats.map((item, index) => <li key={index}>{item.text}</li>)} |
| </ul> |
| </div> |
| )} |
| {detail.open_questions && detail.open_questions.length > 0 && ( |
| <div> |
| <p className="font-medium text-slate-900">Open questions</p> |
| <ul className="mt-1 list-disc space-y-1 pl-5"> |
| {detail.open_questions.map((item, index) => <li key={index}>{item.text}</li>)} |
| </ul> |
| </div> |
| )} |
| </div> |
| )} |
| </article> |
| ) : ( |
| <EmptyState title="No report selected" description="Generate a report or select an existing version." /> |
| )} |
| </> |
| )} |
| </section> |
| ); |
| } |
|
|