249 lines
9.5 KiB
React
249 lines
9.5 KiB
React
"use client";
|
|
|
|
import React, { useEffect, useRef, useState } from "react";
|
|
import { loadInvestigation, saveInvestigation } from "@/lib/storage/investigation-storage";
|
|
import Link from "next/link";
|
|
|
|
export default function ReportPage({ params }) {
|
|
const routeId = (typeof params === "object" && params?.id != null) ? String(params.id) : "";
|
|
const [existing, setExisting] = useState(null);
|
|
const [hydrated, setHydrated] = useState(false);
|
|
const [generationLoading, setGenerationLoading] = useState(false);
|
|
const [generationError, setGenerationError] = useState(false);
|
|
const [updateLoading, setUpdateLoading] = useState(false);
|
|
|
|
const generationAttempted = useRef(false);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
setHydrated(false);
|
|
(async () => {
|
|
try {
|
|
const snapshot = await loadInvestigation(routeId);
|
|
if (active) setExisting(snapshot);
|
|
} catch {
|
|
if (active) setGenerationError(true);
|
|
} finally {
|
|
if (active) setHydrated(true);
|
|
}
|
|
})();
|
|
return () => { active = false; };
|
|
}, [routeId]);
|
|
|
|
// First-generation: create report when none persists (v0.58)
|
|
useEffect(() => {
|
|
if (!hydrated) return;
|
|
if (existing?.investigationReport) return;
|
|
if (generationAttempted.current) return;
|
|
generationAttempted.current = true;
|
|
|
|
const situationGraph = existing?.situationGraph;
|
|
const findings = existing?.findings ?? [];
|
|
|
|
if (!situationGraph) {
|
|
setGenerationError(true);
|
|
return;
|
|
}
|
|
|
|
(async () => {
|
|
setGenerationLoading(true);
|
|
try {
|
|
const res = await fetch("/api/cases/overview", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ situationGraph, findings }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
setGenerationError(true);
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
/* ── v0.59a — provenance: record generation revision (does NOT change Investigation revision) ── */
|
|
const rev = existing?.investigationRevision ?? 0;
|
|
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
|
setExisting((p) => {
|
|
void saveInvestigation({ ...p, investigationReport: reportData })
|
|
.catch((error) => console.error("Investigation report save failed", error));
|
|
return { ...p, investigationReport: reportData };
|
|
});
|
|
} else {
|
|
setGenerationError(true);
|
|
}
|
|
} catch {
|
|
setGenerationError(true);
|
|
} finally {
|
|
setGenerationLoading(false);
|
|
}
|
|
})();
|
|
}, [hydrated, existing]);
|
|
|
|
// Manual Report update (v0.59b — freshness manual update)
|
|
const handleUpdateReport = async () => {
|
|
if (updateLoading) return;
|
|
setUpdateLoading(true);
|
|
|
|
const snap = await loadInvestigation(routeId);
|
|
const situationGraph = snap?.situationGraph;
|
|
const findings = snap?.findings ?? [];
|
|
const rev = snap?.investigationRevision ?? 0;
|
|
|
|
if (!situationGraph) {
|
|
setUpdateLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await fetch("/api/cases/overview", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ situationGraph, findings }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
setUpdateLoading(false);
|
|
return;
|
|
}
|
|
|
|
const data = await res.json();
|
|
if (data.success) {
|
|
const reportData = { understanding: data.understanding, plausibleInterpretations: data.plausibleInterpretations, hasPlausibleInterpretations: true, generatedFromRevision: rev };
|
|
setExisting((p) => {
|
|
void saveInvestigation({ ...p, investigationReport: reportData })
|
|
.catch((error) => console.error("Investigation report save failed", error));
|
|
return { ...p, investigationReport: reportData };
|
|
});
|
|
}
|
|
} catch {
|
|
/* failure: retain existing Report and updateAvailable state */
|
|
} finally {
|
|
setUpdateLoading(false);
|
|
}
|
|
};
|
|
|
|
const report = existing?.investigationReport || null;
|
|
const scenario = hydrated ? (existing?.scenario || "") : null;
|
|
|
|
const paragraphs = (report?.understanding || "")
|
|
.split("\n")
|
|
.filter(Boolean);
|
|
|
|
return (
|
|
<main className="mx-auto max-w-[800px] px-6 py-16">
|
|
<h1 className="mb-2 text-[15px] font-bold tracking-[.2em] uppercase text-teal-700/90">
|
|
Investigation Report
|
|
</h1>
|
|
|
|
{/* Report freshness — only when a Report exists */}
|
|
{report ? (
|
|
<div className="mt-6 flex items-center gap-3">
|
|
{existing?.investigationRevision === report.generatedFromRevision ? (
|
|
<span className="text-[11px] font-semibold tracking-wider uppercase text-teal-700/70">Current</span>
|
|
) : (
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-[11px] font-semibold tracking-wider uppercase text-gray-500">Update available</span>
|
|
<span className="text-xs text-gray-400">The investigation has changed since this report was generated.</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleUpdateReport}
|
|
disabled={updateLoading}
|
|
className="rounded-lg border border-teal-600 bg-white px-3 py-1.5 text-[11px] font-semibold tracking-wider uppercase text-teal-700 hover:bg-teal-50 transition disabled:opacity-40"
|
|
>
|
|
{updateLoading ? "Updating…" : "Update report"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Situation */}
|
|
{scenario && (
|
|
<div className="mt-8 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
|
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
|
|
Situation
|
|
</h2>
|
|
<p className="text-base leading-relaxed text-gray-800 whitespace-pre-wrap">
|
|
{scenario}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* What we understand */}
|
|
{report ? (
|
|
<>
|
|
{paragraphs.length > 0 ? (
|
|
paragraphs.map((p, i) => (
|
|
<div key={i} className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
|
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
|
|
What we understand
|
|
</h2>
|
|
<p className="text-base leading-relaxed text-gray-800">{p}</p>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="mt-6 rounded-xl border-[2.5px] border-teal-300/70 bg-gradient-to-b from-teal-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
|
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-teal-700/70">
|
|
What we understand
|
|
</h2>
|
|
<p className="text-base leading-relaxed text-gray-800">{report.understanding || ""}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* What remains plausible — conditional */}
|
|
{report.hasPlausibleInterpretations && report.plausibleInterpretations ? (
|
|
<div className="mt-6 rounded-xl border-[2.5px] border-blue-300/70 bg-gradient-to-b from-blue-50/60 to-white px-8 pt-6 pb-7 shadow-sm">
|
|
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-blue-700/70">
|
|
What remains plausible
|
|
</h2>
|
|
<p className="text-base leading-relaxed text-gray-800 italic">
|
|
{report.plausibleInterpretations}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
/* Skeleton / loading state when no persisted report exists */
|
|
<>
|
|
<div className="mt-8 rounded-xl border-[2.5px] border-gray-200 bg-gray-50/50 px-8 pt-6 pb-7 shadow-sm">
|
|
<h2 className="mb-3 text-[11px] font-bold tracking-[.18em] uppercase text-gray-400">
|
|
What we understand
|
|
</h2>
|
|
{generationLoading ? (
|
|
<div className="flex flex-col gap-3 py-2" aria-live="polite">
|
|
<span className="text-[11px] font-semibold tracking-wider text-gray-400 uppercase">Generating report…</span>
|
|
{[0, 1, 2].map((i) => (
|
|
<div key={i} className="h-4 w-full rounded animate-pulse" style={{ backgroundColor: "rgb(229 231 235)", animationDelay: `${i * 150}ms`, width: i === 1 ? "80%" : i === 2 ? "65%" : "90%" }} />
|
|
))}
|
|
</div>
|
|
) : generationError ? (
|
|
<p className="text-sm text-red-600">Report generation failed. You may try again from the Investigation page.</p>
|
|
) : null}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Back to investigation */}
|
|
<div className="mt-10">
|
|
<Link
|
|
href={`/investigations/${routeId}`}
|
|
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
|
>
|
|
Back to investigation
|
|
</Link>
|
|
</div>
|
|
|
|
{/* Back to portfolio */}
|
|
<div className="mt-3">
|
|
<Link
|
|
href="/"
|
|
className="rounded-lg border border-teal-600 bg-white px-4 py-2 text-sm font-medium text-teal-700 hover:bg-teal-50 transition"
|
|
>
|
|
Back to portfolio
|
|
</Link>
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|