import { useEffect, useRef, useState } from "react"; import type { ReactNode } from "react"; import { apiGet, apiPatch, apiUpload } from "../../lib/api"; import { ModelSelector } from "../ModelSelector"; interface ParserOption { id: string; name: string; description: string; } interface FileUploadPanelProps { sessionId: number; parsedData: Record; onParsedDataUpdate: (dataType: string, data: unknown) => void; onGoToStep2: () => void; onSessionExpired: () => Promise; } interface UploadZone { type: "questions" | "answers"; label: string; description: string; placeholder: string; icon: ReactNode; } function IconQuestions() { return ( ); } function IconAnswers() { return ( ); } // ── Custom date picker ──────────────────────────────────────────────────────── const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; const DAY_ABBR = ["Su","Mo","Tu","We","Th","Fr","Sa"]; function DatePicker({ value, onChange }: { value: string; onChange: (v: string) => void }) { const parsed = value ? value.split("-").map(Number) : null; const now = new Date(); const [open, setOpen] = useState(false); const [viewYear, setViewYear] = useState(parsed ? parsed[0] : now.getFullYear()); const [viewMonth, setViewMonth] = useState(parsed ? parsed[1] - 1 : now.getMonth()); const ref = useRef(null); // Sync calendar view when value changes externally useEffect(() => { if (value) { const [y, m] = value.split("-").map(Number); setViewYear(y); setViewMonth(m - 1); } }, [value]); useEffect(() => { if (!open) return; const handler = (e: MouseEvent) => { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener("mousedown", handler); return () => document.removeEventListener("mousedown", handler); }, [open]); const todayMs = (() => { const d = new Date(); d.setHours(0,0,0,0); return d.getTime(); })(); const selectedMs = value ? new Date(value + "T00:00:00").getTime() : null; const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); const startDow = new Date(viewYear, viewMonth, 1).getDay(); const cells: (number | null)[] = [ ...Array(startDow).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1), ]; while (cells.length % 7 !== 0) cells.push(null); const fmt = () => { if (!value) return "選擇日期"; const [y, m, d] = value.split("-"); return `${y}年${+m}月${+d}日`; }; const selectDay = (day: number) => { const v = `${viewYear}-${String(viewMonth + 1).padStart(2,"0")}-${String(day).padStart(2,"0")}`; onChange(v); setOpen(false); }; const prevMonth = () => { if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1); } else setViewMonth(m => m - 1); }; const nextMonth = () => { if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1); } else setViewMonth(m => m + 1); }; const goToday = () => { const t = new Date(); const v = `${t.getFullYear()}-${String(t.getMonth()+1).padStart(2,"0")}-${String(t.getDate()).padStart(2,"0")}`; onChange(v); setOpen(false); }; return (
{open && (
{/* Header */}
{viewYear}年 {MONTH_NAMES[viewMonth]}
{/* Day headers */}
{DAY_ABBR.map(d => (
{d}
))}
{/* Calendar grid */}
{cells.map((day, i) => { if (!day) return
; const ms = new Date(viewYear, viewMonth, day).getTime(); const isSel = ms === selectedMs; const isTod = ms === todayMs; return ( ); })}
{/* Today shortcut */}
)}
); } // ───────────────────────────────────────────────────────────────────────────── const zones: UploadZone[] = [ { type: "questions", label: "Test Questions", description: "Upload assessment", placeholder: "例如:國中三年級數學期中考,共 20 題選擇題和 5 題計算題", icon: , }, { type: "answers", label: "Answers", description: "Upload answers as (PDF / CSV / 圖片)", placeholder: "例如:A 卷的考生作答一覽表(CSV 第 1 列為題號標題,第 2 列為標準答案,第 3 列起為學生)", icon: , }, ]; export function FileUploadPanel({ sessionId, parsedData, onParsedDataUpdate, onGoToStep2, onSessionExpired, }: FileUploadPanelProps) { const [files, setFiles] = useState>({ questions: [], answers: [], }); const [descriptions, setDescriptions] = useState>({ questions: "", answers: "", }); const [analyzing, setAnalyzing] = useState>({}); const [errors, setErrors] = useState>({}); const [model, setModel] = useState("gpt-5.4"); const [quizDate, setQuizDate] = useState(""); const [title, setTitle] = useState(""); const [parsers, setParsers] = useState([]); const [parserByZone, setParserByZone] = useState>({ questions: "auto", answers: "auto", }); const [advancedOpen, setAdvancedOpen] = useState>({ questions: false, answers: false, }); const fileInputRefs = useRef>({}); useEffect(() => { apiGet<{ parsers: ParserOption[] }>("/api/parsers") .then((res) => setParsers(res.parsers)) .catch(() => {}); }, []); useEffect(() => { apiGet<{ title?: string; quiz_date?: string | null }>(`/api/sessions/${sessionId}`) .then((s) => { if (s.title) setTitle(s.title); if (s.quiz_date) setQuizDate(s.quiz_date); }) .catch(() => {}); }, [sessionId]); const handleFileSelect = (type: string, selected: FileList | null) => { if (!selected) return; setFiles((prev) => ({ ...prev, [type]: Array.from(selected) })); setErrors((prev) => ({ ...prev, [type]: "" })); }; const handleDrop = (type: string, e: React.DragEvent) => { e.preventDefault(); handleFileSelect(type, e.dataTransfer.files); }; const handleAnalyze = async (type: string) => { const typeFiles = files[type]; if (!typeFiles.length) return; setAnalyzing((prev) => ({ ...prev, [type]: true })); setErrors((prev) => ({ ...prev, [type]: "" })); const buildFormData = () => { const formData = new FormData(); formData.append("data_type", type); formData.append("description", descriptions[type] || ""); formData.append("model", model); formData.append("parser", parserByZone[type] || "auto"); for (const f of typeFiles) { formData.append("files", f); } return formData; }; try { let res: { data: unknown }; try { res = await apiUpload<{ data: unknown }>(`/api/sessions/${sessionId}/upload`, buildFormData()); } catch (err) { // The session was likely wiped server-side (e.g. a bulk delete from the // dashboard) — create a fresh one and retry transparently. if (err instanceof Error && err.message === "Session not found") { const newSessionId = await onSessionExpired(); res = await apiUpload<{ data: unknown }>(`/api/sessions/${newSessionId}/upload`, buildFormData()); } else { throw err; } } if (type === "answers") { const data = res.data as { student_answers?: unknown; teacher_answers?: unknown; }; if (data.student_answers) onParsedDataUpdate("student_answers", data.student_answers); if (data.teacher_answers) onParsedDataUpdate("teacher_answers", data.teacher_answers); onParsedDataUpdate("answers", res.data); } else { onParsedDataUpdate(type, res.data); } } catch (err: unknown) { setErrors((prev) => ({ ...prev, [type]: err instanceof Error ? err.message : "Analysis failed", })); } finally { setAnalyzing((prev) => ({ ...prev, [type]: false })); } }; const hasAnyParsedData = Object.keys(parsedData).length > 0; const isAnyAnalyzing = Object.values(analyzing).some(Boolean); const handleDateChange = (val: string) => { setQuizDate(val); if (val) { apiPatch(`/api/sessions/${sessionId}`, { quiz_date: val }).catch(() => {}); } }; const handleTitleBlur = () => { const trimmed = title.trim(); if (!trimmed) return; apiPatch(`/api/sessions/${sessionId}`, { title: trimmed }).catch(() => {}); }; return (
{/* Info note */}

支援任意格式檔案(PDF、圖片、文字檔等)。若檔案中包含圖片、圖表或示意圖,AI 會自動將其轉換為文字描述,以確保後續分析完整準確。

{/* Quiz name + date row */}
setTitle(e.target.value)} onBlur={handleTitleBlur} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }} placeholder="Quiz 1" className="input !py-1.5 text-sm w-40" />
{/* Upload zones */}
{zones.map((zone) => { const zoneData = parsedData[zone.type] as Record | undefined; const isAnalyzing = analyzing[zone.type]; const hasFiles = files[zone.type].length > 0; const hasParsed = !!zoneData; return (
e.preventDefault()} onDrop={(e) => handleDrop(zone.type, e)} >

{zone.label}

{zone.description}

{/* Advanced options toggle: optional AI context + parser choice, collapsed by default so the empty-state card isn't dominated by fields most teachers never touch. */} {advancedOpen[zone.type] && ( <> {/* Per-zone description */}