/* blueyetutor — /students * Student CRUD + per-student tabs: * - 부여한 문제집 — generated workbooks recorded against this student * - 오답노트 만들기 — pick wrong problems from a filtered grid, save, * then generate a 오답노트 PDF (kind="pick" + --review) * * Assumed endpoints: * GET /api/students → Student[] * POST /api/students { name } * DELETE /api/students/{id} * GET /api/students/{id}/wrong → problem_id[] * PUT /api/students/{id}/wrong { ids[] } * GET /api/students/{id}/workbooks → StudentWb[] * POST /api/students/{id}/workbooks/review { problem_ids[] } */ const { useState: stUseState, useMemo: stUseMemo, useEffect: stUseEffect } = React; function StudentsPage() { const store = useStore(); const students = store.students; const initialTab = (typeof hashQueryParam === "function" && hashQueryParam("tab")) || "manage"; const [topTab, setTopTab] = stUseState(initialTab === "assigned" ? "assigned" : "manage"); const [selId, setSelId] = stUseState(students[0] ? students[0].id : null); stUseEffect(() => { if (!students.find(s => s.id === selId) && students[0]) setSelId(students[0].id); }, [students]); // 배정 list 는 teacher 측에서 별도 fetch 가 필요. 기존 Assignments 페이지가 // 진입 시 fetchAssignments() 했었는데, 합쳐졌으니 여기서 마운트 시 한 번. stUseEffect(() => { window.storeActions.fetchAssignments(); }, []); return (
Students

학생 관리·배정 현황·분석을 한 곳에서

{topTab === "assigned" ? ( ) : (
{/* ---- Left: roster ---- */}
}> {students.length === 0 ? (
등록된 학생 없음
) : (
{students.map(s => { const active = s.id === selId; return ( ); })}
)}
{/* ---- Right: student detail ---- */}
{students.length === 0 ? : selId && s.id === selId)} /> }
)}
); } // 전체 배정 history — 옛 #/assignments (teacher 쪽 read-only 현황) 와 동일 내용. // TeacherAssignmentRow 는 Assignments.jsx 에서 window 전역으로 노출됨. function StudentsAssignmentsAll() { const store = useStore(); const list = store.assignments || []; if (list.length === 0) { return ; } return (
{list.map(a => )}
); } function StudentAddBtn() { const [open, setOpen] = stUseState(false); const [name, setName] = stUseState(""); const [email, setEmail] = stUseState(""); const [busy, setBusy] = stUseState(false); const [msg, setMsg] = stUseState(null); // {ok, text} const emailOk = !email.trim() || /\S+@\S+\.\S+/.test(email.trim()); const submit = async () => { setBusy(true); setMsg(null); const r = await window.storeActions.addStudent(name.trim(), email.trim().toLowerCase()); setBusy(false); if (r && r.ok) { setMsg({ ok: true, text: email.trim() ? `추가 완료 — 학생이 ${email.trim().toLowerCase()} 로 '학생' 가입하면 자동 연동되고, 이 이메일로 과제 배정이 됩니다.` : "추가 완료 (이메일 미등록 — 나중에 다시 추가하면 이메일을 채울 수 있어요)." }); setName(""); setEmail(""); } else { setMsg({ ok: false, text: (r && r.message) || "추가에 실패했습니다." }); } }; return ( <> setOpen(false)} footer={ <> }>
setName(e.target.value)} placeholder="학생 이름" /> setEmail(e.target.value)} placeholder="student@example.com" /> {!emailOk && 이메일 형식이 올바르지 않습니다.} {msg && {msg.text}}
); } function StudentDetail({ student }) { const [tab, setTab] = stUseState("wb"); const [confirmDel, setConfirmDel] = stUseState(false); // bootstrap N+1 회피: 학생 detail 진입 시 lazy fetch (wrong + workbooks). // 이미 한번 fetch한 학생은 store 에 set/array 로 들어가 있어서 빈 dict 가 // 아닌 경우만 skip. 학생 전환할 때마다 한 번만 호출. stUseEffect(() => { if (!student) return; const cached = window.storeSnapshot().studentWbs[student.id]; if (cached === undefined) { window.storeActions.loadStudentExtras(student.id); } }, [student && student.id]); if (!student) return null; return (

{student.name}

{student.email ? {student.email} : 이메일 미등록} 오답 {student.n_wrong} 문제집 {student.n_wb}
{confirmDel ? ( <> 정말 삭제? (오답·문제집 기록 모두 삭제) ) : ( )}
{tab === "wb" && } {tab === "assign" && } {tab === "wrong" && } {tab === "analyze" && }
); } // 이 학생의 계정(이메일)에 일반 문제집(History의 워크북)을 배정 → 학생 '내 과제'에 // 뜨고 MCQ 자동채점·FRQ 사진제출이 됨. 백엔드는 P2 의 POST /api/assignments 재사용 // (배정 시 그 이메일 자동 초대). function StudentAssign({ student }) { const store = useStore(); const history = store.history || []; const [busyId, setBusyId] = stUseState(null); const [msg, setMsg] = stUseState(null); // {ok, text} if (!student.email) { return ( 이 학생을 학생 계정에 연동하려면 이메일이 필요합니다. 좌측 목록 상단 추가에서 같은 이름으로 이메일을 넣어 다시 등록하면 연동됩니다 (오답노트용 이름 기록은 유지). ); } if (history.length === 0) { return ; } const assign = async w => { setBusyId(w.id); setMsg(null); const r = await window.storeActions.assignWorkbook(w.id, student.email, w.file_name); setBusyId(null); if (r && r.ok) setMsg({ ok: true, text: `"${w.file_name}" → ${student.email} 배정 완료 (MCQ ${r.n_mcq} · FRQ ${r.n_frq}). 학생 '내 과제'에 표시됩니다.` }); else setMsg({ ok: false, text: "배정 실패: " + ((r && r.message) || "원인 미상") }); }; return (
{student.email} 의 학생 계정에 배정합니다. 학생이 그 이메일로 가입(또는 이미 가입)하면 '내 과제'에서 풀고 MCQ는 자동 채점됩니다.
{msg && {msg.text}} {history.map(w => (
{w.file_name} {w.subject || "?"} {w.qtype || "?"} {w.n || "?"}문제 · {w.created_at}
))}
); } // 부여한 문제집 — 두 source 합쳐서 sub-tab 으로 나눠 본다. // "배정 문제집" = store.assignments 중 student_email === student.email. // 학생 계정에 보내져 MCQ 자동채점·FRQ 사진제출이 되는 라이브 과제. // "오답노트" = studentWbs[s.id] 의 review_pdf 기록. 학생 계정 연동 없음 (출력용). // 박현우 명시 2026-05-20: 두 종류 다 보여야 한다 (예전엔 오답노트만 보였음). function StudentWorkbooks({ student }) { const store = useStore(); const [sub, setSub] = stUseState("assigned"); const [openId, setOpenId] = stUseState(null); // 클릭한 배정 진척 상세 const allAssignments = (store.assignments || []).filter( a => student.email && (a.student_email || "").toLowerCase() === student.email.toLowerCase() ); const allReview = (store.studentWbs[student.id] || []); return (
{sub === "assigned" && ( allAssignments.length === 0 ? ( ) : (
{allAssignments.map(a => ( setOpenId(openId === a.id ? null : a.id)} /> ))}
) )} {sub === "review" && ( allReview.length === 0 ? ( ) : (
{allReview.map(w => (
{w.file_name} {w.kind} {w.n_problems}문제 {w.created_at}
))}
) )}
); } // 배정한 문제집 한 줄 — 클릭하면 진척 상세 (제출 여부 · 점수 · 틀린 문제 · 단원별 통계). // 백엔드 GET /api/assignments/{aid}/detail 가 채워주는 데이터를 lazy fetch. function AssignedWorkbookRow({ a, open, onToggle }) { const [detail, setDetail] = stUseState(null); const [loading, setLoading] = stUseState(false); const [err, setErr] = stUseState(null); const s = a.submission || {}; const submitted = !!s.submitted; React.useEffect(() => { if (!open || detail || loading) return; setLoading(true); setErr(null); window.storeActions.fetchAssignmentDetail(a.id) .then(d => { if (d && d.ok) setDetail(d.detail); else setErr((d && d.message) || "상세를 불러올 수 없어요."); }) .finally(() => setLoading(false)); }, [open]); return (
{open ? : } {a.title} {a.subject || "?"} MCQ {a.n_mcq}{a.n_frq ? ` · FRQ ${a.n_frq}` : ""}
{!submitted ? ( 미제출 ) : ( <> MCQ {s.mcq_correct}/{s.mcq_total} {a.n_frq > 0 && (s.graded ? FRQ {s.frq_score}/{s.frq_max} : FRQ 채점 대기)} )}
{open && (
{!submitted ? ( 학생이 아직 제출하지 않았어요. ) : loading ? (
상세 불러오는 중…
) : err ? ( {err} ) : detail ? ( ) : null}
)}
); } // 백엔드 detail 응답을 받아서: MCQ 표 (qnum · 학생답 · 정답 · O/X) + // 단원별 정답률 + FRQ 채점 결과. function AssignmentDetailView({ detail }) { const wrong = (detail.mcq_items || []).filter(it => !it.correct); return (
{(detail.mcq_total || 0) > 0 && (
MCQ — 맞춤 {detail.mcq_correct}/{detail.mcq_total} · 틀림 {wrong.length}
{(detail.mcq_items || []).length > 0 && ( {detail.mcq_items.map(it => ( ))}
문항학생 답정답단원
{it.n} {it.given || "·"} {it.answer || "?"} {(it.units || []).join(", ") || "-"} {it.correct ? O : X}
)}
)} {(detail.units || []).length > 0 && (
단원별 정답률 (낮은 순)
{detail.units.map(u => { const pct = u.total ? Math.round(u.correct / u.total * 100) : 0; return (
{u.unit}
= 70 ? "var(--ok, #5a8)" : pct >= 40 ? "var(--warn, #c84)" : "var(--danger, #c44)" }} />
{u.correct}/{u.total} · {pct}%
); })}
)} {detail.frq && detail.frq.graded && ( {detail.frq.feedback || "선생님이 채점을 완료했어요."} )}
); } // 학생 분석 탭 — AI 가 학생의 누적 데이터(오답·배정·라벨)를 보고 취약 단원 + 패턴을 // 자연어로 분석. "분석하기" 버튼 누르면 백엔드 호출. function StudentAnalysisTab({ student }) { const [busy, setBusy] = stUseState(false); const [result, setResult] = stUseState(null); // { units, ai_text } 등 const [err, setErr] = stUseState(null); const run = async () => { setBusy(true); setErr(null); const r = await window.storeActions.analyzeStudent(student.id); setBusy(false); if (r && r.ok) setResult(r.analysis); else setErr((r && r.message) || "분석에 실패했어요."); }; return (

학생 취약점 분석

누적 오답 · 배정한 문제집의 MCQ 채점 결과 · 단원/토픽 라벨을 종합해서 약한 영역을 짚어냅니다. 분석하기 누르면 AI가 분석 시작.
{err && {err}} {result && }
); } function StudentAnalysisView({ a }) { const weak = (a.weak_units || []); return (
{weak.length > 0 && (

약한 단원 (정답률 낮은 순)

{weak.map(u => { const pct = u.total ? Math.round(100 * u.correct / u.total) : 0; return (
{u.unit}
= 70 ? "var(--ok, #5a8)" : pct >= 40 ? "var(--warn, #c84)" : "var(--danger, #c44)" }} />
{u.correct}/{u.total} · {pct}%
); })}
)} {a.ai_text && (

AI 분석

{a.ai_text}
)}
); } function StudentWrongTab({ student }) { const store = useStore(); const subjects = Object.keys(TAXONOMY).filter(s => store.pdfs.some(p => p.subject === s)); const [subject, setSubject] = stUseState(subjects[0] || "Physics 1"); const [qtype, setQtype] = stUseState("MCQ"); const [filterUnits, setFilterUnits] = stUseState([]); // wrong set is server-of-truth — seed from store, mutate locally, save on click. const seed = store.studentWrong[student.id] || new Set(); const [wrong, setWrong] = stUseState(seed); stUseEffect(() => { setWrong(new Set(store.studentWrong[student.id] || new Set())); }, [student.id]); const counts = stUseMemo(() => window.unitsFor(subject, qtype), [subject, qtype, store.problems]); const cb_units = TAXONOMY[subject] || []; const unitOpts = cb_units.filter(u => counts[u] > 0).map(u => `${u} (${counts[u]})`); const unitMap = Object.fromEntries(cb_units.map(u => [`${u} (${counts[u] || 0})`, u])); const problems = stUseMemo(() => window.browseProblems(subject, qtype, filterUnits), [subject, qtype, filterUnits, store.problems]); const names = Object.fromEntries(store.pdfs.map(p => [p.pdf_id, p.display_name || p.pdf_id])); const visibleIds = new Set(problems.map(p => p.id)); const visibleWrong = [...wrong].filter(id => visibleIds.has(id)).length; const toggle = id => { const next = new Set(wrong); if (next.has(id)) next.delete(id); else next.add(id); setWrong(next); }; const [genBusy, setGenBusy] = stUseState(false); const [genMsg, setGenMsg] = stUseState(null); // {ok, text} const saveWrong = async () => { await window.storeActions.setStudentWrong(student.id, wrong); }; const genReviewPdf = async () => { setGenBusy(true); setGenMsg(null); const r = await window.storeActions.generateReviewPdf(student.id, wrong); setGenBusy(false); if (r && r.ok) setGenMsg({ ok: true, text: "오답노트 PDF가 생성·다운로드되었습니다." }); else setGenMsg({ ok: false, text: "오답노트 생성 실패: " + ((r && r.message) || "원인 미상") }); }; return (
{problems.length === 0 ? ( 표시할 문제 없음 — 필터를 바꿔보세요. ) : (
{problems.map(p => ( toggle(p.id)} source={names[p.pdf_id]} scoringPaths={qtype === "FRQ" ? p.scoring_paths : undefined} /> ))}
)}

오답노트 PDF 생성

현재 선택된 오답 {wrong.size}문제로 한 권의 PDF를 만듭니다.
{genMsg && ( {genMsg.text} )}
); } Object.assign(window, { StudentsPage });