/* 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}개)
) : (
학생이 아직 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 && (
)}
{n > 0 && (
MCQ ({n}문제) — 정답을 클릭해서 선택하세요. 복수정답 문제는 여러 개 클릭.
{Array.from({ length: n }, (_, i) => String(i + 1)).map(q => {
const sel = ans[q] || new Set();
return (
{q}.
{_MCQ_OPTIONS.map(L => (
))}
);
})}
)}
{a.n_frq > 0 && (
)}
{submitted && (
다시 제출하면 답안이 갱신됩니다. 점수는 선생님이 직접 확인해서 알려줘요.
)}
{sub.graded && sub.feedback && (
{sub.feedback}
)}
{err &&
{err}}
)}
);
}
Object.assign(window, { AssignmentsPage, TeacherAssignmentRow, StudentAssignment });