/* blueyetutor — /assignments (role-aware) * * student : 내 과제 목록 → PDF 인라인 보기 + 다운로드 + MCQ 선택형 제출 * + FRQ 사진/파일 업로드 (점수는 학생에게 비공개) * teacher : 배정 현황 (학생별 제출/MCQ 점수/FRQ 채점 상태) + 채점 UI * * GET /api/assignments → 역할별 목록 * GET /api/assignments/{id}/pdf → 문제집 PDF (inline; iframe·blob 둘 다) * POST /api/assignments/{id}/submit → { mcq:{n:answer} } → MCQ 자동채점 * POST /api/assignments/{id}/frq → multipart files (이미지·PDF 혼합 허용) */ const { useState: asUseState, useEffect: asUseEffect } = React; const _MCQ_OPTIONS = ["A", "B", "C", "D", "E"]; const _isPdfName = n => /\.pdf$/i.test(n || ""); function AssignmentsPage() { const store = useStore(); const role = (store.user && store.user.role) || "teacher"; asUseEffect(() => { window.storeActions.fetchAssignments(); }, []); const list = store.assignments || []; return (
Assignments

{role === "student" ? "내 과제" : "배정 현황"}

{list.length === 0 ? ( ) : (
{list.map(a => role === "student" ? : )}
)}
); } // ---------- teacher: read-only status row ---------- function TeacherAssignmentRow({ a }) { const s = a.submission || {}; const [open, setOpen] = asUseState(false); const gradable = a.n_frq > 0 && s.submitted; const [score, setScore] = asUseState(s.frq_score != null ? String(s.frq_score) : ""); const [smax, setSmax] = asUseState(s.frq_max != null ? String(s.frq_max) : String(a.n_frq || "")); const [fb, setFb] = asUseState(s.feedback || ""); const [busy, setBusy] = asUseState(false); const [msg, setMsg] = asUseState(""); const save = async () => { setBusy(true); setMsg(""); const r = await window.storeActions.gradeFrq(a.id, parseFloat(score) || 0, parseFloat(smax) || 0, fb.trim()); setBusy(false); setMsg(r && r.ok ? "채점 저장됨" : "저장 실패: " + ((r && r.message) || "")); }; return (
setOpen(!open)}> {open ? : } {a.title} {a.subject || "—"} {a.student_email} MCQ {a.n_mcq} · FRQ {a.n_frq}
{!s.submitted ? 미제출 : ( <> MCQ {s.mcq_correct}/{s.mcq_total} {a.n_frq > 0 && (s.graded ? FRQ {s.frq_score}/{s.frq_max} : FRQ 채점 대기{s.frq_count ? ` · 첨부 ${s.frq_count}` : " · 첨부 없음"})} )}
{open && (
{!gradable ? (
{a.n_frq === 0 ? "FRQ 없는 과제입니다 (MCQ 자동채점만)." : "학생이 아직 제출하지 않았습니다."}
) : ( <> {s.frq_count > 0 ? (
학생 FRQ 첨부 ({s.frq_count}개)
{(s.frq_files || Array.from({ length: s.frq_count }, () => "")).map((nm, i) => { const href = `/api/assignments/${a.id}/frq/${i}`; const pdf = _isPdfName(nm); return ( {pdf ? (
{nm || `FRQ ${i + 1}.pdf`}
) : ( {`FRQ )}
); })}
) : ( 학생이 아직 FRQ 답안을 올리지 않았습니다. )}
setScore(e.target.value)} style={{ width: 90 }} /> setSmax(e.target.value)} style={{ width: 90 }} /> setFb(e.target.value)} placeholder="학생에게 보일 코멘트" />
{msg &&
{msg}
} )}
)} ); } // AI grading helpers — buttons only, wiring lands later. v1 = transcription // (손글씨 → LaTeX/평문), v2 = rubric-based draft 채점 (선생님 override). function FrqAiAssistPlaceholder() { return (
AI 채점 보조 준비 중
학생이 손으로 푼 풀이 사진을 AI가 텍스트로 옮겨주거나, rubric 기준 채점 초안을 제시합니다. 선생님은 그대로 저장하거나 수정해서 확정합니다.
); } // ---------- student: solve + submit ---------- // MCQ answers are stored client-side as { [qnum]: Set }; the wire // format stays "B" or "B,D" so the existing backend grader (which already // splits on comma + normalises) doesn't change. function StudentAssignment({ a }) { const sub = a.submission || {}; const [open, setOpen] = asUseState(!sub.submitted); const [ans, setAns] = asUseState({}); // { q: Set } const [busy, setBusy] = asUseState(false); const [err, setErr] = asUseState(""); // Track *whether* it's been submitted; the actual MCQ score is intentionally // hidden from the student per teacher request. const [submitted, setSubmitted] = asUseState(!!sub.submitted); const [showPdf, setShowPdf] = asUseState(false); const n = a.n_mcq || 0; const fileRef = React.useRef(null); const camRef = React.useRef(null); const [frqBusy, setFrqBusy] = asUseState(false); const [frqMsg, setFrqMsg] = asUseState(""); const toggleLetter = (q, L) => setAns(prev => { const cur = new Set(prev[q] || []); if (cur.has(L)) cur.delete(L); else cur.add(L); return { ...prev, [q]: cur }; }); const _wireMcq = () => { const out = {}; for (const q of Object.keys(ans)) { const arr = [...(ans[q] || [])].sort(); // canonical "B,D" if (arr.length) out[q] = arr.join(","); } return out; }; const submit = async () => { setBusy(true); setErr(""); const r = await window.storeActions.submitMcq(a.id, _wireMcq()); setBusy(false); if (!r || !r.ok) { setErr((r && r.message) || "제출에 실패했습니다."); return; } setSubmitted(true); }; const uploadFrq = async e => { const files = Array.from(e.target.files || []); if (!files.length) return; setFrqBusy(true); setFrqMsg(""); const r = await window.storeActions.uploadFrq(a.id, files); setFrqBusy(false); if (fileRef.current) fileRef.current.value = ""; if (camRef.current) camRef.current.value = ""; setFrqMsg(r && r.ok ? `${r.count}개 업로드 완료 — 선생님이 채점합니다.` : "업로드 실패: " + ((r && r.message) || "")); }; const dlName = a.title ? `${a.title}.pdf` : "assignment.pdf"; return (
setOpen(!open)}> {open ? : } {a.title} {a.subject || "—"} MCQ {a.n_mcq}{a.n_frq ? ` · FRQ ${a.n_frq}` : ""}
{submitted ? 제출 완료 : 미제출}
{open && (
새 탭에서 열기
{showPdf && (