ClassLens / chatkit /frontend /src /components /step3 /ReportViewer.tsx
kuanz's picture
Add database management: Supabase persistence, teacher profiles, admin dashboard (#1)
4c4a722
Raw
History Blame Contribute Delete
6.99 kB
import { useState, useRef, useEffect } from "react";
import type { StudentReport } from "../../App";
interface ReportViewerProps {
reports: StudentReport[];
isGenerating: boolean;
onBack: () => void;
onExportAllHtml: () => void;
onSaveToDb?: () => Promise<{ students_saved: number }>;
}
export function ReportViewer({
reports,
isGenerating,
onBack,
onExportAllHtml,
onSaveToDb,
}: ReportViewerProps) {
const [activeIndex, setActiveIndex] = useState(0);
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
const [saveMsg, setSaveMsg] = useState("");
const iframeRef = useRef<HTMLIFrameElement>(null);
const handleSaveToDb = async () => {
if (!onSaveToDb) return;
setSaveState("saving");
setSaveMsg("");
try {
const res = await onSaveToDb();
setSaveState("saved");
setSaveMsg(`已儲存 ${res.students_saved} 位學生成績`);
} catch (e) {
setSaveState("error");
setSaveMsg(e instanceof Error ? e.message : "儲存失敗");
}
};
// Auto-switch to the report currently being generated
useEffect(() => {
const generatingIdx = reports.findIndex((r) => r.status === "generating");
if (generatingIdx >= 0) {
setActiveIndex(generatingIdx);
} else {
// When generation finishes, show the last completed one
let lastDone = -1;
for (let j = reports.length - 1; j >= 0; j--) {
if (reports[j].status === "done") { lastDone = j; break; }
}
if (lastDone >= 0) setActiveIndex(lastDone);
}
}, [reports]);
const activeReport = reports[activeIndex];
const doneCount = reports.filter((r) => r.status === "done").length;
const errorCount = reports.filter((r) => r.status === "error").length;
const handleDownloadSingle = () => {
if (!activeReport?.html) return;
const blob = new Blob([activeReport.html], { type: "text/html" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `classlens-${activeReport.studentName}.html`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-4">
{/* Progress bar */}
{isGenerating && (
<div className="card p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-semibold text-[var(--color-text)]">
生成進度
</span>
<span className="text-sm text-[var(--color-text-muted)]">
{doneCount + errorCount} / {reports.length}
</span>
</div>
<div className="w-full h-2 bg-[var(--color-border)] rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-[var(--color-primary)] to-[var(--color-success)] transition-all duration-500"
style={{
width: `${((doneCount + errorCount) / reports.length) * 100}%`,
}}
/>
</div>
</div>
)}
{/* Action bar */}
<div className="flex flex-wrap items-center gap-3">
<button onClick={onBack} className="btn btn-outline text-sm">
← 返回選擇學生
</button>
<div className="flex-1" />
{activeReport?.status === "done" && (
<button onClick={handleDownloadSingle} className="btn btn-outline text-sm">
下載此報告
</button>
)}
{doneCount > 1 && (
<button onClick={onExportAllHtml} className="btn btn-outline text-sm">
下載全部報告
</button>
)}
{onSaveToDb && !isGenerating && doneCount > 0 && (
<button
onClick={handleSaveToDb}
disabled={saveState === "saving" || saveState === "saved"}
className="btn btn-primary text-sm disabled:opacity-60"
>
{saveState === "saving" && "儲存中..."}
{saveState === "saved" && "✅ 已儲存"}
{(saveState === "idle" || saveState === "error") && "儲存至資料庫"}
</button>
)}
</div>
{saveMsg && (
<p
className={`text-sm text-right ${
saveState === "error" ? "text-red-400" : "text-[var(--color-success)]"
}`}
>
{saveMsg}
</p>
)}
{/* Student tabs */}
<div className="flex gap-2 overflow-x-auto pb-2">
{reports.map((r, i) => (
<button
key={r.studentIndex}
onClick={() => setActiveIndex(i)}
className={`shrink-0 px-4 py-2 rounded-xl text-sm font-semibold transition-all ${
i === activeIndex
? "bg-[var(--color-primary)] text-white shadow-md"
: "bg-[var(--color-surface)] text-[var(--color-text-muted)] hover:text-[var(--color-text)] border border-[var(--color-border)]"
}`}
>
<span className="mr-1.5">
{r.status === "done" && "✅"}
{r.status === "generating" && "⏳"}
{r.status === "pending" && "⬜"}
{r.status === "error" && "❌"}
</span>
{r.studentName}
</button>
))}
</div>
{/* Report content */}
<div className="card overflow-hidden" style={{ height: "70vh" }}>
{activeReport?.status === "done" && (
<iframe
ref={iframeRef}
srcDoc={activeReport.html}
title={`Report - ${activeReport.studentName}`}
className="w-full h-full border-0"
sandbox="allow-scripts allow-same-origin"
/>
)}
{activeReport?.status === "generating" && (
<div className="w-full h-full flex flex-col items-center justify-center gap-4">
<div className="w-10 h-10 border-3 border-[var(--color-primary)] border-t-transparent rounded-full animate-spin" />
<p className="text-[var(--color-text-muted)] text-lg">
正在為 <strong className="text-[var(--color-text)]">{activeReport.studentName}</strong> 生成報告...
</p>
</div>
)}
{activeReport?.status === "pending" && (
<div className="w-full h-full flex items-center justify-center">
<p className="text-[var(--color-text-muted)]">
等待生成...
</p>
</div>
)}
{activeReport?.status === "error" && (
<div className="w-full h-full flex flex-col items-center justify-center gap-3">
<span className="text-4xl"></span>
<p className="text-[var(--color-text)] font-semibold">
{activeReport.studentName} 報告生成失敗
</p>
<p className="text-sm text-[var(--color-text-muted)] max-w-md text-center">
{activeReport.error}
</p>
</div>
)}
</div>
</div>
);
}