/* 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 (
setSelId(s.id)}>
{s.name}
{s.email ? {s.email} : 이메일 미등록 }
{" · "}오답 {s.n_wrong} · 문제집 {s.n_wb}
);
})}
)}
{/* ---- 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
{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}
window.storeActions.downloadStudentWorkbook(student.id, w.id, w.file_name)}>
다운로드
))}
)
)}
);
}
// 배정한 문제집 한 줄 — 클릭하면 진척 상세 (제출 여부 · 점수 · 틀린 문제 · 단원별 통계).
// 백엔드 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 (
{(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가 분석 시작.
{busy ? "분석 중…" : "분석하기"}
{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 && (
)}
);
}
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 (
setSubject(e.target.value)}>
{subjects.map(s => {s} )}
`${u} (${counts[u] || 0})`)}
onChange={e => setFilterUnits(Array.from(e.target.selectedOptions).map(o => unitMap[o.value]).filter(Boolean))}>
{unitOpts.map(o => {o} )}
{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를 만듭니다.
{genBusy ? "생성 중…" : `오답노트 생성 (${wrong.size}문제)`}
{genMsg && (
{genMsg.text}
)}
);
}
Object.assign(window, { StudentsPage });