// 관리 화면: 로그인, 대시보드, 피드백, 용어·관계 검수, 은하·단원, 설정, 기록. import { esc, api, toast, fmtDate, debounce, STATUS_LABEL, FEEDBACK_STATUS_LABEL, REL_TYPE_LABEL, METHOD_LABEL } from "./common.js"; const $ = (sel, root = document) => root.querySelector(sel); const $$ = (sel, root = document) => [...root.querySelectorAll(sel)]; const TERM_STATUSES = ["draft", "review", "confirmed"]; const REL_STATUSES = ["draft", "review", "confirmed", "rejected"]; const KIND_LABEL = { content: "내용 오류", bug: "화면·기능 오류", search: "검색 문제", suggestion: "제안", client_error: "화면 자동 오류", server_error: "서버 자동 오류", }; const ENTITY_LABEL = { account: "교수자 계정", signup_code: "가입 코드", term: "용어", relation: "관계", feedback: "피드백", galaxy: "은하", toc: "단원", settings: "설정", atlas: "데이터 가져오기", member: "회원", class: "반", question: "문제" }; const ACTION_LABEL = { create: "만들기", update: "수정", delete: "삭제", bulk: "일괄 변경", merge: "합치기", reseed: "다시 가져오기", seed: "가져오기" }; const S = { me: null, role: null, // owner | reviewer structure: null, // {books, galaxies, toc} galaxyById: {}, bookById: {}, tocById: {}, sections: [], // 주 교재 단원 itemsBySection: {}, editor: null, // 용어 편집 상태 hash: "", }; // ---------------------------------------------------------------- 공통 도우미 class AuthError extends Error {} async function aapi(path, opts) { try { return await api(path, opts); } catch (e) { if (e.status === 401 && !path.endsWith("/login")) { showLogin(); const ae = new AuthError("로그인이 필요합니다."); ae.status = 401; throw ae; } throw e; } } function fail(e) { if (e instanceof AuthError) return; toast(e.message || "요청에 실패했습니다.", true); } const badge = (st, labels = STATUS_LABEL) => st ? `${esc(labels[st] || st)}` : ""; const opt = (v, label, cur) => ``; const opts = (obj, cur) => Object.entries(obj).map(([k, l]) => opt(k, l, cur)).join(""); const studentLink = (id, text = "학생 화면") => `${esc(text)}`; const termLink = (id, name) => `${esc(name || id)}`; const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : 0); const loading = (msg = "불러오는 중…") => `
${esc(msg)}
`; const empty = (msg) => `
${esc(msg)}
`; function prettyJSON(v) { if (v == null || v === "") return ""; let o = v; if (typeof v === "string") { try { o = JSON.parse(v); } catch { return v; } } return JSON.stringify(o, null, 2); } function jsonBlock(v, summary = "자세히", open = false) { const s = prettyJSON(v); if (!s) return `—`; return `
${esc(summary)}
${esc(s)}
`; } function evidenceText(ev) { if (!Array.isArray(ev) || !ev.length) return ""; return ev.map((e) => `${S.bookById[e.book_id]?.short || e.book_id || ""}${e.page ? ` p.${e.page}` : ""}`).join(", "); } function pager(total, offset, limit) { if (total <= limit) return `
총 ${total}건
`; const from = total ? offset + 1 : 0, to = Math.min(total, offset + limit); return `
총 ${total}건 중 ${from}–${to}
`; } function bindPager(root, f, reload) { root.addEventListener("click", (e) => { const b = e.target.closest("[data-page]"); if (!b || b.disabled) return; f.offset = Math.max(0, f.offset + (b.dataset.page === "next" ? f.limit : -f.limit)); reload(); }); } // 별칭 안에 쉼표가 있으면 줄바꿈으로 구분해 보여 준다. 줄바꿈이 있으면 줄 단위로만 나눈다. function listToText(a) { a = a || []; return a.some((x) => String(x).includes(",")) ? a.join("\n") : a.join(", "); } function textToList(s) { s = String(s || ""); return s.split(s.includes("\n") ? /\n/ : /,/).map((x) => x.trim()).filter(Boolean); } function numOrNull(v) { const n = parseInt(v, 10); return Number.isFinite(n) ? n : null; } function params(obj) { const p = new URLSearchParams(); for (const [k, v] of Object.entries(obj)) if (v !== "" && v != null) p.set(k, v); return p.toString(); } async function loadStructure(force = false) { if (S.structure && !force) return S.structure; const d = await aapi("/api/admin/structure"); S.structure = d; S.galaxyById = Object.fromEntries(d.galaxies.map((g) => [g.galaxy_id, g])); S.bookById = Object.fromEntries(d.books.map((b) => [b.book_id, b])); S.tocById = Object.fromEntries(d.toc.map((t) => [t.toc_id, t])); const primary = new Set(d.books.filter((b) => b.is_primary).map((b) => b.book_id)); S.sections = d.toc.filter((t) => t.kind === "section" && primary.has(t.book_id)); S.itemsBySection = {}; for (const t of d.toc) if (t.kind === "item" && t.parent_id) (S.itemsBySection[t.parent_id] ||= []).push(t); return d; } const galaxyName = (id) => S.galaxyById[id]?.name || (id ? id : ""); const secLabel = (t) => t ? `${t.number ? t.number + ". " : ""}${t.title || ""}` : ""; const bookShort = (id) => S.bookById[id]?.short || id || ""; function galaxyOptions(cur, emptyLabel = "전체 은하") { return opt("", emptyLabel, cur) + (S.structure?.galaxies || []).map((g) => opt(g.galaxy_id, `${g.chapter_no ?? ""}. ${g.name}`, cur)).join(""); } function sectionOptions(galaxy, cur, emptyLabel = "전체 단원") { const list = S.sections.filter((s) => !galaxy || s.galaxy_id === galaxy); return opt("", emptyLabel, cur) + list.map((s) => opt(s.toc_id, `[${bookShort(s.book_id)}] ${secLabel(s)}`, cur)).join(""); } function bookOptions(cur, emptyLabel) { return (emptyLabel != null ? opt("", emptyLabel, cur) : "") + (S.structure?.books || []).map((b) => opt(b.book_id, b.short || b.title, cur)).join(""); } // 용어 자동 완성 (관리자용 검색 API 사용) let acSeq = 0; function termPicker(root, { placeholder = "용어 이름으로 찾기", label = "용어 찾기", exclude = () => null, onPick } = {}) { const id = `ac-${++acSeq}`; root.innerHTML = `
`; const input = $("input", root), list = $("ul", root); let items = [], active = -1, chosen = null, req = 0; const close = () => { list.hidden = true; list.innerHTML = ""; active = -1; input.setAttribute("aria-expanded", "false"); input.removeAttribute("aria-activedescendant"); }; const setActive = (i) => { const lis = $$("[data-i]", list); if (!lis.length) return; active = (i + lis.length) % lis.length; lis.forEach((li, k) => li.setAttribute("aria-selected", String(k === active))); input.setAttribute("aria-activedescendant", lis[active].id); lis[active].scrollIntoView({ block: "nearest" }); }; const pick = (i) => { const t = items[i]; if (!t) return; chosen = t; input.value = t.name; close(); onPick && onPick(t); }; const search = debounce(async () => { const q = input.value.trim(); if (chosen && input.value !== chosen.name) chosen = null; if (!q) return close(); const my = ++req; try { const d = await aapi(`/api/admin/terms?${params({ q, limit: 12 })}`); if (my !== req) return; const ex = exclude(); items = d.items.filter((t) => t.term_id !== ex); list.innerHTML = items.length ? items.map((t, i) => `
  • ${esc(t.name)}${t.hidden ? ' 숨김' : ""} ${esc(galaxyName(t.galaxy_id))} · ${esc(t.term_id)}
  • `).join("") : `
  • 찾는 용어가 없습니다.
  • `; list.hidden = false; input.setAttribute("aria-expanded", "true"); active = -1; } catch (e) { fail(e); } }, 200); input.addEventListener("input", search); input.addEventListener("keydown", (e) => { if (list.hidden) return; if (e.key === "ArrowDown") { e.preventDefault(); setActive(active + 1); } else if (e.key === "ArrowUp") { e.preventDefault(); setActive(active - 1); } else if (e.key === "Enter") { e.preventDefault(); if (active >= 0) pick(active); } else if (e.key === "Escape") { e.stopPropagation(); close(); } }); list.addEventListener("mousedown", (e) => { const li = e.target.closest("[data-i]"); if (li) { e.preventDefault(); pick(+li.dataset.i); } }); input.addEventListener("blur", () => setTimeout(close, 150)); return { get: () => chosen, clear() { chosen = null; input.value = ""; close(); }, input }; } // ---------------------------------------------------------------- 로그인·라우팅 const appView = $("#app-view"), view = $("#view"); function showLogin() { // 로그인은 공용 로그인 페이지에서 (교수자 아이디·비밀번호) location.replace("/login?next=" + encodeURIComponent(location.pathname + location.hash)); } function showApp() { appView.hidden = false; $("#who").hidden = false; $("#who-name").textContent = `${S.me} 님 · 교수자`; $("#who-name").title = "관리 → 설정에서 아이디·비밀번호를 바꿀 수 있습니다"; refreshMemberCount(); } $("#logout").addEventListener("click", async () => { if (!confirmLeave()) return; try { await api("/api/admin/logout", { method: "POST" }); } catch { /* 무시 */ } S.structure = null; location.href = "/"; }); function editorDirty() { return !!(S.editor && S.editor.isDirty && S.editor.isDirty()); } function confirmLeave() { if (!editorDirty()) return true; if (confirm("저장하지 않은 변경 사항이 있습니다. 이 화면을 떠날까요?")) { S.editor = null; return true; } return false; } window.addEventListener("beforeunload", (e) => { if (editorDirty()) { e.preventDefault(); e.returnValue = ""; } }); window.addEventListener("hashchange", () => { const h = location.hash.slice(1); if (h === S.hash) return; if (!confirmLeave()) { history.replaceState(null, "", "#" + S.hash); return; } route(); }); const VIEWS = {}; async function route(focus = false) { if (!S.me) return; let h = location.hash.slice(1) || (location.pathname === "/members" ? "members" : "dashboard"); const [name, ...rest] = h.split("/"); const arg = rest.join("/") ? decodeURIComponent(rest.join("/")) : null; const viewName = VIEWS[name] ? name : "dashboard"; if (!VIEWS[name]) h = "dashboard"; S.hash = h; S.editor = null; if (viewName === "feedback" && S.view === "feedback" && $("#ff-list")) { try { if (arg) await showFeedbackDetail(+arg); else { $("#fb-split").classList.remove("has-detail"); $("#fb-detail").hidden = true; $$("#ff-list tr.sel").forEach((tr) => { tr.classList.remove("sel"); tr.removeAttribute("aria-selected"); }); } } catch (e) { fail(e); } return; } S.view = viewName; const tab = viewName === "term" ? "terms" : viewName; document.title = (viewName === "members" ? "회원 관리" : "관리") + " · 지역사회간호학 개념 우주"; $$("#tabs a").forEach((a) => (a.dataset.tab === tab ? a.setAttribute("aria-current", "page") : a.removeAttribute("aria-current"))); view.innerHTML = loading(); try { await loadStructure(); await VIEWS[viewName](arg); } catch (e) { if (e instanceof AuthError) return; view.innerHTML = `
    화면을 불러오지 못했습니다: ${esc(e.message)}
    `; toast(e.message, true); } if (focus) view.focus({ preventScroll: true }); } async function refreshFeedbackCount() { try { const d = await aapi("/api/admin/feedback?status=open&limit=1"); const c = $("#tab-fb-count"); c.textContent = d.total; c.hidden = !d.total; } catch { /* 무시 */ } } // ---------------------------------------------------------------- 1. 대시보드 VIEWS.dashboard = async () => { const s = await aapi("/api/admin/stats"); const tot = TERM_STATUSES.reduce((a, k) => a + (s.terms[k] || 0), 0); const relTot = Object.values(s.relations).reduce((a, b) => a + b, 0); const openFb = Object.values(s.feedback_open_by_kind).reduce((a, b) => a + b, 0); const meter = (n, d) => ``; const kv = (obj, labels, cls) => Object.keys(obj).length ? `` : `

    없음

    `; view.innerHTML = `

    대시보드

    파이프라인 생성: ${esc(fmtDate(s.meta.generated_at) || "—")} · DB 가져온 시각: ${esc(fmtDate(s.meta.seeded_at) || "—")}

    ${loading()}

    용어 검수 상태

    ${tot.toLocaleString()}개 (숨김 ${esc(s.terms_hidden)})
      ${TERM_STATUSES.map((k) => `
    • ${badge(k)}${(s.terms[k] || 0).toLocaleString()}
    • `).join("")}
    ${meter(s.terms.confirmed || 0, tot)}

    확정 ${pct(s.terms.confirmed || 0, tot)}%

    쉬운 뜻

    ${pct(s.terms_easy, tot)}%
    ${meter(s.terms_easy, tot)}

    ${s.terms_easy.toLocaleString()} / ${tot.toLocaleString()}개 · 빠진 용어 보기

    교과서 정의

    ${pct(s.terms_definition, tot)}%
    ${meter(s.terms_definition, tot)}

    ${s.terms_definition.toLocaleString()} / ${tot.toLocaleString()}개 · 빠진 용어 보기

    단원 추정

    ${s.terms_section_guess.toLocaleString()}개

    목차에서 직접 찾지 못해 단원을 추정한 용어입니다. · 확인하기

    관계 검수 상태

    ${relTot.toLocaleString()}개
    ${kv(s.relations, STATUS_LABEL, true)}

    관계 생성 방법

    ${kv(s.relations_by_method, METHOD_LABEL)}

    열린 피드백

    ${openFb}건
    ${kv(s.feedback_open_by_kind, KIND_LABEL)}

    피드백 처리하러 가기

    은하별 검수 현황

    ${s.by_galaxy.map((g) => ``).join("")}
    은하용어확정확정 비율
    ${esc(g.name)} ${esc(g.terms)}${esc(g.confirmed || 0)}
    ${meter(g.confirmed || 0, g.terms)}${pct(g.confirmed || 0, g.terms)}%

    데이터 관리

    파이프라인(data/atlas.json)을 다시 돌린 뒤 결과를 가져옵니다. 사람이 고친 용어·관계·은하·단원은 그대로 유지되고, 고치지 않은 자동 항목만 새 결과로 바뀝니다.

    전체 내보내기(JSON)
    `; renderQuickCode($("#quick-code")); $("#dash").addEventListener("click", gotoTermsHandler); $("#reseed").addEventListener("click", async (e) => { if (!confirm("파이프라인 결과(data/atlas.json)를 다시 가져올까요?\n\n사람이 고친 항목은 유지되고, 고치지 않은 자동 항목만 갱신됩니다.")) return; e.target.disabled = true; e.target.textContent = "가져오는 중…"; try { const r = await aapi("/api/admin/reseed", { method: "POST" }); const L = { terms_new: "새 용어", terms_updated: "갱신된 용어", terms_kept: "유지된(수정된) 용어", terms_removed: "삭제된 용어", relations_new: "새 관계", relations_updated: "갱신된 관계", relations_kept: "유지된(수정된) 관계", relations_removed: "삭제된 관계" }; $("#reseed-result").innerHTML = ``; toast("다시 가져왔습니다."); await loadStructure(true); } catch (err) { fail(err); } finally { e.target.disabled = false; e.target.textContent = "파이프라인 결과 다시 가져오기"; } }); }; function gotoTermsHandler(e) { const a = e.target.closest("[data-goto-terms]"); if (!a) return; e.preventDefault(); Object.assign(TF, { q: "", galaxy: "", section: "", status: "", missing: "", hidden: "0", offset: 0 }, JSON.parse(a.dataset.gotoTerms)); location.hash = "#terms"; } // ---------------------------------------------------------------- 2. 피드백 const FF = { status: "open", kind: "", q: "", limit: 100, offset: 0, items: [] }; VIEWS.feedback = async (arg) => { view.innerHTML = `

    피드백

    ${loading()}
    `; const reload = async () => { FF.offset = FF.offset || 0; await loadFeedbackList(); }; $("#ff-status").addEventListener("change", (e) => { FF.status = e.target.value; FF.offset = 0; reload(); }); $("#ff-kind").addEventListener("change", (e) => { FF.kind = e.target.value; FF.offset = 0; reload(); }); $("#ff-q").addEventListener("input", debounce((e) => { FF.q = e.target.value.trim(); FF.offset = 0; reload(); }, 300)); bindPager($("#ff-list"), FF, reload); $("#ff-list").addEventListener("click", (e) => { if (e.target.closest("a, button")) return; const tr = e.target.closest("tr[data-id]"); if (tr) location.hash = `#feedback/${tr.dataset.id}`; }); $("#ff-list").addEventListener("keydown", (e) => { const tr = e.target.closest("tr[data-id]"); if (tr && (e.key === "Enter" || e.key === " ") && e.target === tr) { e.preventDefault(); location.hash = `#feedback/${tr.dataset.id}`; } }); await loadFeedbackList(); if (arg) await showFeedbackDetail(+arg); refreshFeedbackCount(); }; async function loadFeedbackList() { const box = $("#ff-list"); if (!box) return; const d = await aapi(`/api/admin/feedback?${params({ status: FF.status, kind: FF.kind, q: FF.q, limit: FF.limit, offset: FF.offset })}`); FF.items = d.items; const cur = S.hash.startsWith("feedback/") ? S.hash.split("/")[1] : null; box.innerHTML = d.items.length ? `
    ${d.items.map((f) => ``).join("")}
    #접수유형대상내용상태
    ${f.id} ${esc(fmtDate(f.created_at))} ${esc(KIND_LABEL[f.kind] || f.kind)}
    ${esc(f.category || "")}
    ${fbTarget(f)} ${esc(f.message)}${f.occurrences > 1 ? ` ×${f.occurrences}` : ""} ${badge(f.status, FEEDBACK_STATUS_LABEL)}
    ${pager(d.total, FF.offset, FF.limit)}` : empty("조건에 맞는 피드백이 없습니다."); } function fbTarget(f) { if (f.target_type === "term" && f.target_id) { return `${termLink(f.target_id, f.target_label)} ${studentLink(f.target_id, "↗")}`; } if (f.target_type === "relation" && f.target_id) return `관계 ${esc(f.target_id)}${f.target_label ? `
    ${esc(f.target_label)}` : ""}`; return f.target_label ? esc(f.target_label) : `—`; } async function showFeedbackDetail(id) { const pane = $("#fb-detail"); let f = FF.items.find((x) => x.id === id); if (!f) { f = await aapi(`/api/admin/feedback/${id}`).catch(() => null); } $("#fb-split").classList.add("has-detail"); pane.hidden = false; if (!f) { pane.innerHTML = `
    ${empty(`#${id} 피드백을 찾을 수 없습니다.`)}
    `; return; } $$("#ff-list tr[data-id]").forEach((tr) => { const on = +tr.dataset.id === id; tr.classList.toggle("sel", on); tr.toggleAttribute("aria-selected", on); }); const isTerm = f.target_type === "term" && f.target_id; pane.innerHTML = `

    #${f.id} ${esc(f.category || KIND_LABEL[f.kind] || "")}

    닫기

    ${badge(f.status, FEEDBACK_STATUS_LABEL)} ${esc(KIND_LABEL[f.kind] || f.kind)} · 접수 ${esc(fmtDate(f.created_at))}${f.updated_at && f.updated_at !== f.created_at ? ` · 수정 ${esc(fmtDate(f.updated_at))}` : ""}${f.handled_by ? ` · 처리자 ${esc(f.handled_by)}` : ""} ${f.occurrences > 1 ? `${f.occurrences}회 발생` : ""}

    대상
    ${fbTarget(f)}${isTerm ? ` 용어 편집 열기` : ""}
    내용
    ${esc(f.message)}
    ${f.suggestion ? `
    제안
    ${esc(f.suggestion)}
    ` : ""} ${f.page_url ? `
    화면 주소
    ${esc(f.page_url)}
    ` : ""} ${f.reporter || f.contact ? `
    신고자
    ${esc(f.reporter || "(이름 없음)")}${f.contact ? ` · ${esc(f.contact)}` : ""}
    ` : ""} ${f.user_agent ? `
    브라우저
    ${esc(f.user_agent)}
    ` : ""} ${f.context ? `
    상세 정보
    ${jsonBlock(f.context, "오류 상세(스택 등) 펼치기", (f.kind || "").endsWith("_error"))}
    ` : ""}
    빠른 처리:
    `; const form = $("#fb-edit"); const save = async (status) => { const body = { status: status || form.status.value, resolution: form.resolution.value, admin_note: form.admin_note.value }; try { const r = await aapi(`/api/admin/feedback/${id}`, { method: "PATCH", body }); Object.assign(f, r); toast("저장했습니다."); await loadFeedbackList(); await showFeedbackDetail(id); refreshFeedbackCount(); } catch (e) { fail(e); } }; form.addEventListener("submit", (e) => { e.preventDefault(); save(); }); $$("[data-quick]", form).forEach((b) => b.addEventListener("click", () => save(b.dataset.quick))); $("#fb-del").addEventListener("click", async () => { if (!confirm(`#${id} 피드백을 삭제할까요? 되돌릴 수 없습니다.`)) return; try { await aapi(`/api/admin/feedback/${id}`, { method: "DELETE" }); toast("삭제했습니다."); await loadFeedbackList(); refreshFeedbackCount(); location.hash = "#feedback"; } catch (e) { fail(e); } }); if (window.innerWidth < 1100) pane.scrollIntoView({ behavior: "smooth", block: "start" }); } function safeUrl(u) { try { const x = new URL(u, location.origin); return /^https?:$/.test(x.protocol) ? x.href : "#"; } catch { return "#"; } } // ---------------------------------------------------------------- 3. 용어 목록 const TF = { q: "", galaxy: "", section: "", status: "", missing: "", hidden: "0", sort: "name", limit: 100, offset: 0, selected: new Set() }; VIEWS.terms = async () => { TF.selected.clear(); view.innerHTML = `

    용어

    새 용어
    ${loading()}
    `; const reload = () => loadTermList(); const set = (k) => (e) => { TF[k] = e.target.value; TF.offset = 0; reload(); }; $("#tf-q").addEventListener("input", debounce((e) => { TF.q = e.target.value.trim(); TF.offset = 0; reload(); }, 300)); $("#tf-galaxy").addEventListener("change", (e) => { TF.galaxy = e.target.value; if (TF.section && S.tocById[TF.section]?.galaxy_id !== TF.galaxy) TF.section = ""; $("#tf-section").innerHTML = sectionOptions(TF.galaxy, TF.section); TF.offset = 0; reload(); }); ["section", "status", "missing", "hidden", "sort"].forEach((k) => $(`#tf-${k}`).addEventListener("change", set(k))); bindPager($("#tf-list"), TF, reload); $("#tf-list").addEventListener("change", (e) => { if (e.target.id === "tf-all") { $$("#tf-list input[data-sel]").forEach((c) => { c.checked = e.target.checked; c.checked ? TF.selected.add(c.dataset.sel) : TF.selected.delete(c.dataset.sel); }); } else if (e.target.dataset.sel) { e.target.checked ? TF.selected.add(e.target.dataset.sel) : TF.selected.delete(e.target.dataset.sel); } updateBulk(); }); $("#tf-clear").addEventListener("click", () => { TF.selected.clear(); $$("#tf-list input[type=checkbox]").forEach((c) => (c.checked = false)); updateBulk(); }); $$("[data-bulk]", $("#tf-bulk")).forEach((b) => b.addEventListener("click", async () => { const ids = [...TF.selected]; if (!ids.length) return; const body = { ids, ...JSON.parse(b.dataset.bulk) }; if (!confirm(`선택한 ${ids.length}개 용어를 ‘${b.textContent.trim()}’ 처리할까요?`)) return; try { const r = await aapi("/api/admin/terms/bulk", { method: "POST", body }); toast(`${r.count}개 용어를 바꿨습니다.`); TF.selected.clear(); reload(); } catch (e) { fail(e); } })); await loadTermList(); }; function updateBulk() { const n = TF.selected.size; const bar = $("#tf-bulk"); if (!bar) return; bar.hidden = !n; $("#tf-selcount").textContent = `${n}개 선택`; const all = $("#tf-all"); if (all) { const boxes = $$("#tf-list input[data-sel]"); all.checked = boxes.length > 0 && boxes.every((c) => c.checked); all.indeterminate = !all.checked && boxes.some((c) => c.checked); } } async function loadTermList() { const box = $("#tf-list"); if (!box) return; const d = await aapi(`/api/admin/terms?${params({ q: TF.q, galaxy: TF.galaxy, section: TF.section, status: TF.status, missing: TF.missing, hidden: TF.hidden, sort: TF.sort, limit: TF.limit, offset: TF.offset })}`); if (!$("#tf-list")) return; box.innerHTML = d.items.length ? `
    ${d.items.map((t) => { const sec = S.tocById[t.section_id]; return ` `; }).join("")}
    이름은하단원상태 쉬운 뜻정의수정
    ${termLink(t.term_id, t.name)} ${t.kind === "person" ? '인물' : ""}${t.lang === "en" ? '영어' : ""}${t.origin === "manual" ? '직접 추가' : ""}${t.hidden ? '숨김' : ""} ${t.aliases?.length ? `
    ${esc(t.aliases.join(", "))}
    ` : ""}
    ${esc(galaxyName(t.galaxy_id))} ${sec ? esc(secLabel(sec)) : '—'}${t.section_guess ? ' 추정' : ""} ${badge(t.review_status)} ${t.has_easy ? '✓' : '·'} ${t.has_def ? '✓' : '·'} ${t.edited ? '✎ ' : ""}${t.reviewer ? esc(t.reviewer) + "
    " : ""}${esc(fmtDate(t.updated_at))}
    ${pager(d.total, TF.offset, TF.limit)}` : empty("조건에 맞는 용어가 없습니다."); updateBulk(); } // ---------------------------------------------------------------- 4. 용어 편집 const KIND_OPTS = { concept: "개념", person: "인물" }; const METHOD_NOTE = { auto: "자동 추출", wireframe: "시안 예시", manual: "직접 입력", draft: "AI 초안(교재 문장 기반)" }; // 화면 값과 같은 방식으로 정규화해, 열자마자 '변경됨'으로 보이지 않게 한다. function termValues(t) { const s = (v) => (v == null ? null : String(v).trim() || null); return { name: (t.name || "").trim(), kind: t.kind || "concept", aliases: textToList(listToText(t.aliases)), search_terms: textToList(listToText(t.search_terms)), galaxy_id: t.galaxy_id || null, section_id: t.section_id || null, item_id: t.item_id || null, easy: s(t.easy), example: s(t.example), definition_text: s(t.definition_text), definition_book: t.definition_book || null, definition_page: t.definition_page ?? null, review_status: t.review_status || "draft", hidden: !!t.hidden, note: s(t.note), }; } VIEWS.term = async (arg) => { const creating = !arg || arg === "new"; const t = creating ? { name: "", kind: "concept", review_status: "draft", sources: [], relations: [], feedback: [], history: [] } : await aapi(`/api/admin/terms/${encodeURIComponent(arg)}`); renderTermEditor(t, creating); }; function renderTermEditor(t, creating) { const orig = termValues(t); const ed = { t, creating, orig, sourcesTouched: false }; view.innerHTML = `
    ← 용어 목록

    ${creating ? "새 용어" : esc(t.name)} ${creating ? "" : badge(t.review_status)}${t.hidden ? ' 숨김' : ""}

    ${creating ? "" : `${esc(t.term_id)} ${studentLink(t.term_id, "학생 화면에서 보기")} · 문제`}

    기본 정보

    단원만 고르면 은하는 서버에서 단원에 맞춰 정해집니다.${t.section_guess ? " 이 용어의 단원은 자동으로 추정된 것이니 확인해 주세요." : ""}

    설명

    출처 (색인 위치)

    ‘정의로 사용’을 누르면 해당 문장을 교과서 정의 칸에 옮기고 교재·쪽수를 맞춥니다.

    교재쪽PDF쪽위치·문장동작

    검수

    검수 상태 ${TERM_STATUSES.map((k) => ``).join("")}
    ${creating ? "" : `마지막 수정: ${esc(t.reviewer || "자동")} · ${esc(fmtDate(t.updated_at))}`}
    `; const form = $("#te-form"); const fillSections = () => { const g = form.galaxy_id.value; const cur = form.section_id.dataset.cur ?? orig.section_id; let html = sectionOptions(g, cur, "(없음)"); // 현재 값이 주 교재 단원이 아니어도 보이게 한다. if (cur && !S.sections.some((s) => s.toc_id === cur && (!g || s.galaxy_id === g))) { const s = S.tocById[cur]; html += opt(cur, s ? `[${bookShort(s.book_id)}] ${secLabel(s)}${s.galaxy_id !== g ? " (다른 은하)" : ""}` : cur, cur); } form.section_id.innerHTML = html; fillItems(); }; const fillItems = () => { const sec = form.section_id.value; const cur = form.item_id.dataset.cur ?? orig.item_id; const items = S.itemsBySection[sec] || []; let html = opt("", "(없음)", cur) + items.map((i) => opt(i.toc_id, secLabel(i), cur)).join(""); if (cur && !items.some((i) => i.toc_id === cur)) { const i = S.tocById[cur]; html += opt(cur, i ? `${secLabel(i)}${i.parent_id !== sec ? " (다른 단원)" : ""}` : cur, cur); } form.item_id.innerHTML = html; }; fillSections(); form.galaxy_id.addEventListener("change", () => { const s = S.tocById[form.section_id.value]; const keep = s && s.galaxy_id === form.galaxy_id.value; form.section_id.dataset.cur = keep ? form.section_id.value : ""; form.item_id.dataset.cur = keep ? form.item_id.value : ""; fillSections(); markDirty(); }); form.section_id.addEventListener("change", () => { form.section_id.dataset.cur = form.section_id.value; form.item_id.dataset.cur = ""; const s = S.tocById[form.section_id.value]; if (s && s.galaxy_id && form.galaxy_id.value !== s.galaxy_id) { form.galaxy_id.value = s.galaxy_id; } fillItems(); markDirty(); }); form.item_id.addEventListener("change", () => { form.item_id.dataset.cur = form.item_id.value; }); // 출처 const srcBody = $("#src-body"); const srcRow = (s) => ` `; srcBody.innerHTML = (t.sources || []).map(srcRow).join("") || ""; const srcEmpty = () => { if (!srcBody.children.length) srcBody.innerHTML = `출처가 없습니다.`; }; srcEmpty(); $("#src-add").addEventListener("click", () => { $(".empty-row", srcBody)?.remove(); const primary = S.structure.books.find((b) => b.is_primary); srcBody.insertAdjacentHTML("beforeend", srcRow({ book_id: primary?.book_id })); ed.sourcesTouched = true; markDirty(); $("tr:last-child input[data-f=page]", srcBody).focus(); }); srcBody.addEventListener("input", () => { ed.sourcesTouched = true; markDirty(); }); srcBody.addEventListener("click", (e) => { const b = e.target.closest("[data-act]"); if (!b) return; const tr = b.closest("tr"); if (b.dataset.act === "del-src") { tr.remove(); ed.sourcesTouched = true; srcEmpty(); markDirty(); } else if (b.dataset.act === "use-def") { const snip = $("[data-f=snippet]", tr).value.trim(); if (!snip) { toast("문장이 비어 있습니다.", true); return; } form.definition_text.value = snip; form.definition_book.value = $("[data-f=book_id]", tr).value; form.definition_page.value = $("[data-f=page]", tr).value; markDirty(); form.definition_text.focus(); toast("정의 칸에 옮겼습니다. 저장해야 반영됩니다."); } }); const readSources = () => $$("tr[data-id]", srcBody).map((tr) => { const g = (f) => $(`[data-f=${f}]`, tr).value; const o = { book_id: g("book_id"), page: numOrNull(g("page")), pdf_page: numOrNull(g("pdf_page")), path: g("path").trim() || null, snippet: g("snippet").trim() || null }; if (tr.dataset.id) o.id = +tr.dataset.id; if (tr.dataset.section) o.section_id = tr.dataset.section; return o; }); const current = () => ({ name: form.name.value.trim(), kind: form.kind.value, aliases: textToList(form.aliases.value), search_terms: textToList(form.search_terms.value), galaxy_id: form.galaxy_id.value || null, section_id: form.section_id.value || null, item_id: form.item_id.value || null, easy: form.easy.value.trim() || null, example: form.example.value.trim() || null, definition_text: form.definition_text.value.trim() || null, definition_book: form.definition_book.value || null, definition_page: numOrNull(form.definition_page.value), review_status: (form.querySelector("input[name=review_status]:checked") || {}).value || "draft", hidden: form.hidden.checked, note: form.note.value.trim() || null, }); const diff = () => { const c = current(), out = {}; for (const k of Object.keys(c)) if (JSON.stringify(c[k]) !== JSON.stringify(ed.orig[k])) out[k] = c[k]; return out; }; ed.isDirty = () => Object.keys(diff()).length > 0 || ed.sourcesTouched; const markDirty = () => { const el = $("#te-dirty"); if (el) el.textContent = ed.isDirty() ? "저장하지 않은 변경 사항이 있습니다." : ""; }; form.addEventListener("input", markDirty); form.addEventListener("change", markDirty); S.editor = ed; form.addEventListener("submit", async (e) => { e.preventDefault(); const d = diff(); const body = creating ? Object.fromEntries(Object.entries(current()).filter(([, v]) => v !== null && !(Array.isArray(v) && !v.length))) : d; if (!body.name && creating) { toast("이름을 입력해 주세요.", true); form.name.focus(); return; } if ("name" in body && !body.name) { toast("이름은 비울 수 없습니다.", true); form.name.focus(); return; } if (ed.sourcesTouched) { const src = readSources(); if (src.some((s) => !s.book_id)) { toast("출처의 교재를 골라 주세요.", true); return; } body.sources = src; } if (!creating && !Object.keys(body).length) { toast("바뀐 내용이 없습니다."); return; } const btn = $("#te-save"); btn.disabled = true; try { const r = creating ? await aapi("/api/admin/terms", { method: "POST", body }) : await aapi(`/api/admin/terms/${encodeURIComponent(t.term_id)}`, { method: "PUT", body }); toast(creating ? "용어를 만들었습니다." : "저장했습니다."); S.editor = null; if (creating) { S.hash = `term/${r.term_id}`; history.replaceState(null, "", `#term/${encodeURIComponent(r.term_id)}`); } const y = window.scrollY; renderTermEditor(r, false); window.scrollTo(0, y); } catch (err) { fail(err); btn.disabled = false; } }); if (creating) { form.name.focus(); return; } // 관계 renderTermRelations(t); const picker = termPicker($("#rel-add-picker"), { label: "상대 용어 찾기", exclude: () => t.term_id }); $("#rel-add").addEventListener("submit", async (e) => { e.preventDefault(); const f = e.target, other = picker.get(); if (!other) { toast("상대 용어를 목록에서 골라 주세요.", true); picker.input.focus(); return; } const out = f.dir.value === "out"; const body = { source: out ? t.term_id : other.term_id, target: out ? other.term_id : t.term_id, type: f.type.value, label: f.label.value.trim() || null, reason: f.reason.value.trim() || null }; try { await aapi("/api/admin/relations", { method: "POST", body }); toast("관계를 추가했습니다."); picker.clear(); f.label.value = ""; f.reason.value = ""; await reloadTermRelations(t); } catch (err) { fail(err); } }); const mp = termPicker($("#merge-picker"), { label: "합칠 대상 용어 찾기", placeholder: "합칠 대상 용어", exclude: () => t.term_id }); $("#merge-btn").addEventListener("click", async () => { const into = mp.get(); if (!into) { toast("합칠 대상 용어를 목록에서 골라 주세요.", true); mp.input.focus(); return; } if (editorDirty() && !confirm("저장하지 않은 변경 사항은 사라집니다. 계속할까요?")) return; if (!confirm(`‘${t.name}’을(를) ‘${into.name}’(으)로 합칠까요?\n출처·관계·별칭이 옮겨지고 ‘${t.name}’은(는) 숨겨집니다.`)) return; try { const r = await aapi(`/api/admin/terms/${encodeURIComponent(t.term_id)}/merge`, { method: "POST", body: { into: into.term_id } }); toast("합쳤습니다."); S.editor = null; location.hash = `#term/${encodeURIComponent(r.term_id)}`; } catch (err) { fail(err); } }); } async function reloadTermRelations(t) { const fresh = await aapi(`/api/admin/terms/${encodeURIComponent(t.term_id)}`); t.relations = fresh.relations; renderTermRelations(t); } function renderTermRelations(t) { const box = $("#te-rels"); if (!box) return; if (!t.relations.length) { box.innerHTML = `

    연결된 관계가 없습니다.

    `; return; } box.innerHTML = ``; box.onclick = async (e) => { const b = e.target.closest("[data-act]"); if (!b) return; const li = b.closest("li[data-rid]"), rid = li.dataset.rid; if (b.dataset.act === "save") { const body = { type: $("[data-f=type]", li).value, label: $("[data-f=label]", li).value.trim() || null, review_status: $("[data-f=review_status]", li).value }; try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "PUT", body }); toast("관계를 저장했습니다."); await reloadTermRelations(t); } catch (err) { fail(err); } } else if (b.dataset.act === "del") { if (!confirm("이 관계를 삭제할까요? 자동으로 만든 관계는 다시 생기지 않도록 ‘반려·숨김’ 처리됩니다.")) return; try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "DELETE" }); toast("관계를 삭제했습니다."); await reloadTermRelations(t); } catch (err) { fail(err); } } }; } // ---------------------------------------------------------------- 5. 관계 const RF = { status: "draft", type: "", method: "", galaxy: "", q: "", limit: 100, offset: 0, selected: new Set() }; VIEWS.relations = async () => { RF.selected.clear(); view.innerHTML = `

    관계

    관계 추가
    시작 용어
    도착 용어
    ${loading()}
    `; const reload = () => loadRelList(); $("#rf-q").addEventListener("input", debounce((e) => { RF.q = e.target.value.trim(); RF.offset = 0; reload(); }, 300)); ["status", "type", "method", "galaxy"].forEach((k) => $(`#rf-${k}`).addEventListener("change", (e) => { RF[k] = e.target.value; RF.offset = 0; reload(); })); bindPager($("#rf-list"), RF, reload); const src = termPicker($("#ra-src"), { label: "시작 용어 찾기", placeholder: "시작 용어" }); const tgt = termPicker($("#ra-tgt"), { label: "도착 용어 찾기", placeholder: "도착 용어", exclude: () => src.get()?.term_id }); $("#ra-form").addEventListener("submit", async (e) => { e.preventDefault(); const f = e.target; if (!src.get() || !tgt.get()) { toast("시작·도착 용어를 목록에서 골라 주세요.", true); return; } try { await aapi("/api/admin/relations", { method: "POST", body: { source: src.get().term_id, target: tgt.get().term_id, type: f.type.value, label: f.label.value.trim() || null, reason: f.reason.value.trim() || null } }); toast("관계를 추가했습니다."); src.clear(); tgt.clear(); f.label.value = ""; f.reason.value = ""; reload(); } catch (err) { fail(err); } }); const list = $("#rf-list"); list.addEventListener("change", (e) => { if (e.target.id === "rf-all") { $$("input[data-sel]", list).forEach((c) => { c.checked = e.target.checked; c.checked ? RF.selected.add(c.dataset.sel) : RF.selected.delete(c.dataset.sel); }); } else if (e.target.dataset.sel) { e.target.checked ? RF.selected.add(e.target.dataset.sel) : RF.selected.delete(e.target.dataset.sel); } updateRelBulk(); }); list.addEventListener("click", async (e) => { const b = e.target.closest("[data-act]"); if (!b) return; const tr = b.closest("tr[data-rid]"), rid = tr.dataset.rid; if (b.dataset.act === "save") { const body = { type: $("[data-f=type]", tr).value, label: $("[data-f=label]", tr).value.trim() || null, review_status: $("[data-f=review_status]", tr).value }; try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "PUT", body }); toast("저장했습니다."); tr.classList.add("saved"); } catch (err) { fail(err); } } else if (b.dataset.act === "del") { if (!confirm("이 관계를 삭제할까요? 자동으로 만든 관계는 ‘반려·숨김’ 처리됩니다.")) return; try { await aapi(`/api/admin/relations/${encodeURIComponent(rid)}`, { method: "DELETE" }); toast("삭제했습니다."); reload(); } catch (err) { fail(err); } } }); $("#rf-clear").addEventListener("click", () => { RF.selected.clear(); $$("input[type=checkbox]", list).forEach((c) => (c.checked = false)); updateRelBulk(); }); $$("[data-bulk]", $("#rf-bulk")).forEach((b) => b.addEventListener("click", async () => { const ids = [...RF.selected]; if (!ids.length) return; if (!confirm(`선택한 ${ids.length}개 관계를 ‘${b.textContent.trim()}’ 처리할까요?`)) return; try { const r = await aapi("/api/admin/relations/bulk", { method: "POST", body: { ids, ...JSON.parse(b.dataset.bulk) } }); toast(`${r.count}개 관계를 바꿨습니다.`); RF.selected.clear(); reload(); } catch (err) { fail(err); } })); await loadRelList(); }; function updateRelBulk() { const n = RF.selected.size, bar = $("#rf-bulk"); if (!bar) return; bar.hidden = !n; $("#rf-selcount").textContent = `${n}개 선택`; const all = $("#rf-all"); if (all) { const boxes = $$("#rf-list input[data-sel]"); all.checked = boxes.length > 0 && boxes.every((c) => c.checked); all.indeterminate = !all.checked && boxes.some((c) => c.checked); } } async function loadRelList() { const box = $("#rf-list"); if (!box) return; const d = await aapi(`/api/admin/relations?${params({ status: RF.status, type: RF.type, method: RF.method, galaxy: RF.galaxy, q: RF.q, limit: RF.limit, offset: RF.offset })}`); if (!$("#rf-list")) return; box.innerHTML = d.items.length ? `
    ${d.items.map((r) => ``).join("")}
    연결유형설명방법·근거상태동작
    ${termLink(r.source, r.source_name)} ${r.directed ? "→" : "↔"} ${termLink(r.target, r.target_name)} ${r.hidden ? ' 숨김' : ""}${r.reason ? `
    ${esc(r.reason)}
    ` : ""}
    ${esc(METHOD_LABEL[r.method] || r.method || "")}${evidenceText(r.evidence) ? `
    ${esc(evidenceText(r.evidence))}` : ""}
    ${pager(d.total, RF.offset, RF.limit)}` : empty("조건에 맞는 관계가 없습니다."); updateRelBulk(); } // ---------------------------------------------------------------- 6. 은하·단원 VIEWS.structure = async () => { const d = await loadStructure(true); const secsBy = {}; for (const s of S.sections) (secsBy[s.galaxy_id || ""] ||= []).push(s); const secRow = (s) => ` ${esc(bookShort(s.book_id))}
    ${esc(s.number || "")} · p.${esc(s.page ?? "")} ${s.edited ? '✎ ' : ""}`; const secTable = (list) => list?.length ? `
    ${list.map(secRow).join("")}
    교재단원 제목쉬운 보조 설명은하동작
    ` : `

    주 교재 단원이 없습니다.

    `; view.innerHTML = `

    은하·단원

    은하는 큰 단원(장), 단원은 주 교재의 절입니다. 고친 항목은 파이프라인 결과를 다시 가져와도 유지됩니다.

    ${d.galaxies.map((g) => `
    ${esc(g.chapter_no ?? "")}
    ${g.edited ? ' ✎' : ""}
    단원 ${(secsBy[g.galaxy_id] || []).length}개${secTable(secsBy[g.galaxy_id])}
    `).join("")} ${secsBy[""]?.length ? `

    은하가 없는 단원

    ${secTable(secsBy[""])}
    ` : ""}
    `; $$(".g-form", view).forEach((f) => f.addEventListener("submit", async (e) => { e.preventDefault(); const body = { name: f.name.value.trim(), sub: f.sub.value.trim(), color: f.color.value }; if (!body.name) { toast("은하 이름을 입력해 주세요.", true); return; } try { await aapi(`/api/admin/galaxies/${encodeURIComponent(f.dataset.gid)}`, { method: "PUT", body }); toast("은하를 저장했습니다."); await loadStructure(true); } catch (err) { fail(err); } })); $("#st-root").addEventListener("click", async (e) => { const b = e.target.closest("[data-act=save-toc]"); if (!b) return; const tr = b.closest("tr[data-toc]"); const body = { title: $("[data-f=title]", tr).value.trim(), easy_sub: $("[data-f=easy_sub]", tr).value.trim() || null, galaxy_id: $("[data-f=galaxy_id]", tr).value || null }; if (!body.title) { toast("단원 제목을 입력해 주세요.", true); return; } const moved = S.tocById[tr.dataset.toc]?.galaxy_id !== body.galaxy_id; try { await aapi(`/api/admin/toc/${encodeURIComponent(tr.dataset.toc)}`, { method: "PUT", body }); toast(moved ? "단원을 저장했습니다. 은하를 옮겼으니 목록을 새로 고칩니다." : "단원을 저장했습니다."); if (moved) VIEWS.structure(); else await loadStructure(true); } catch (err) { fail(err); } }); }; // ---------------------------------------------------------------- 7. 설정 VIEWS.settings = async () => { const [s, acc] = await Promise.all([aapi("/api/admin/settings"), aapi("/api/admin/account")]); const me = acc.items[0] || {}; const pub = new Set(s.public_statuses || []); view.innerHTML = `

    설정

    교수자 계정

    관리 화면은 이 계정으로만 들어올 수 있습니다. 로그인은 /login 에서 합니다. 바꾸려면 현재 비밀번호를 입력하세요.

    최근 로그인: ${esc(fmtDate(me.last_login) || "-")}

    학생 화면에 보여줄 검수 상태

    학생 화면에 보여줄 검수 상태입니다. 검수가 진행되면 ‘확정’만 남기세요.

    ${TERM_STATUSES.map((k) => ``).join("")}
    `; $("#acc-form").addEventListener("submit", async (e) => { e.preventDefault(); const f = e.target; if (f.new_password.value !== f.new_password2.value) return toast("새 비밀번호 확인이 일치하지 않습니다.", true); const body = { current_password: f.current_password.value, username: f.username.value.trim(), name: f.name.value.trim() }; if (f.new_password.value) body.new_password = f.new_password.value; try { const r = await aapi("/api/admin/account", { method: "PUT", body }); S.me = r.name || r.username; $("#who-name").textContent = `${S.me} 님 · 교수자`; f.current_password.value = f.new_password.value = f.new_password2.value = ""; toast("계정을 저장했습니다."); } catch (err) { fail(err); } }); $("#set-form").addEventListener("submit", async (e) => { e.preventDefault(); const f = e.target; const statuses = $$("input[name=public_statuses]:checked", f).map((c) => c.value); if (!statuses.length) { toast("최소 한 가지 상태는 골라 주세요.", true); return; } try { await aapi("/api/admin/settings", { method: "PUT", body: { public_statuses: statuses, show_status_badges: f.show_status_badges.checked } }); toast("설정을 저장했습니다."); } catch (err) { fail(err); } }); }; // 기록 화면 필터 상태 const AF = { entity: "", limit: 50, offset: 0 }; VIEWS.audit = async () => { view.innerHTML = `

    기록

    ${loading()}
    `; const reload = () => loadAudit(); $("#af-entity").addEventListener("change", (e) => { AF.entity = e.target.value; AF.offset = 0; reload(); }); bindPager($("#af-list"), AF, reload); await loadAudit(); }; async function loadAudit() { const box = $("#af-list"); if (!box) return; const d = await aapi(`/api/admin/audit?${params({ entity: AF.entity, limit: AF.limit, offset: AF.offset })}`); if (!$("#af-list")) return; const entLink = (a) => { if (!a.entity_id) return `—`; if (a.entity === "term" && a.action !== "bulk") return termLink(a.entity_id); if (a.entity === "feedback") return `#${esc(a.entity_id)}`; return `${esc(a.entity_id.length > 60 ? a.entity_id.slice(0, 60) + "…" : a.entity_id)}`; }; box.innerHTML = d.items.length ? `
    ${d.items.map((a) => ``).join("")}
    시각검수자동작대상ID이전이후
    ${esc(fmtDate(a.at))}${esc(a.actor || "")} ${esc(ACTION_LABEL[a.action] || a.action)}${esc(ENTITY_LABEL[a.entity] || a.entity || "")} ${entLink(a)}${jsonBlock(a.before, "보기")}${jsonBlock(a.after, "보기")}
    ${pager(d.total, AF.offset, AF.limit)}` : empty("기록이 없습니다."); } // ---------------------------------------------------------------- 회원·반 (소유자 전용) const MEMBER_STATUS = { pending: "대기", approved: "이용 중", rejected: "거절", disabled: "사용 중지" }; const MEMBER_BADGE = { pending: "new", approved: "confirmed", rejected: "rejected", disabled: "duplicate" }; async function refreshMemberCount() { // 가입 코드로 바로 가입하므로 승인 대기 표시는 없음 } VIEWS.members = async (arg) => { const [sub, subArg] = (arg || "list").split("/"); const [m, c] = await Promise.all([aapi("/api/admin/members"), aapi("/api/admin/classes")]); const classes = c.items; const counts = m.counts || {}; const classOpts = (cur, blank = "반 선택") => `` + classes.map((k) => opt(k.class_id, k.name, cur)).join(""); const rate = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "-"); const SUBS = [["list", `회원 목록 ${m.items.length}`], ["codes", "가입 코드"], ["classes", "반"], ["stats", "학습 현황"]]; const cur = sub === "u" ? "list" : sub; view.innerHTML = `

    회원 관리

    학생은 수업에서 알려 준 가입 코드를 넣으면 바로 회원이 되어 사례와 문제를 볼 수 있습니다. 코드마다 반이 정해져 있어 그 반으로 들어갑니다.

    `; const body = $("#mem-body"); const reload = () => VIEWS.members(arg); const patch = async (uid, b, msg) => { try { await aapi(`/api/admin/members/${uid}`, { method: "PATCH", body: b }); toast(msg); refreshMemberCount(); reload(); } catch (e) { fail(e); } }; const memberRow = (u, pendingMode) => ` ${esc(u.username)}${esc(u.name)}${esc(u.student_no || "")} ${pendingMode ? `${esc(u.note || "")}${esc(u.signup_code || "")}${esc(fmtDate(u.created_at))}` : `${badge(u.status, MEMBER_STATUS).replace(`badge ${u.status}`, `badge ${MEMBER_BADGE[u.status]}`)}`} ${pendingMode ? "" : `${u.attempts} · ${rate(u.correct, u.attempts)}${esc(fmtDate(u.last_login))}`} ${pendingMode ? ` ` : ` ${u.status === "approved" ? `` : ``}`}`; const wireRows = () => $$("tr[data-uid]", body).forEach((tr) => { const uid = tr.dataset.uid; const cls = () => +$("[data-class]", tr).value || null; $("[data-approve]", tr)?.addEventListener("click", () => cls() ? patch(uid, { status: "approved", class_id: cls() }, "승인했습니다.") : toast("승인할 반을 골라 주세요.", true)); $("[data-reject]", tr)?.addEventListener("click", () => confirm("가입을 거절할까요?") && patch(uid, { status: "rejected" }, "거절했습니다.")); $("[data-move]", tr)?.addEventListener("click", () => cls() ? patch(uid, { class_id: cls() }, "반을 바꿨습니다.") : toast("반을 골라 주세요.", true)); $("[data-disable]", tr)?.addEventListener("click", () => confirm("이 회원의 사용을 중지할까요?") && patch(uid, { status: "disabled" }, "사용을 중지했습니다.")); $("[data-enable]", tr)?.addEventListener("click", () => cls() ? patch(uid, { status: "approved", class_id: cls() }, "다시 사용할 수 있게 했습니다.") : toast("반을 골라 주세요.", true)); }); const noClassWarn = classes.length ? "" : `

    반이 없습니다. 반을 먼저 만든 뒤 가입 코드를 만들어 주세요.

    `; if (cur === "list") { body.innerHTML = `
    `; const draw = () => { const q = $("#mf-q").value.trim().toLowerCase(), st = $("#mf-status").value, k = $("#mf-class").value; const items = m.items.filter((u) => (!st || u.status === st) && (!k || String(u.class_id) === k) && (!q || [u.username, u.name, u.student_no].some((x) => (x || "").toLowerCase().includes(q)))); $("#mf-list").innerHTML = items.length ? `

    ${items.length}명

    ${items.map((u) => memberRow(u, false)).join("")}
    아이디이름학번상태반푼 문제·정답률최근 로그인
    ` : empty("조건에 맞는 회원이 없습니다."); wireRows(); }; ["#mf-q", "#mf-status", "#mf-class"].forEach((s) => $(s).addEventListener("input", debounce(draw, 120))); draw(); if (sub === "u" && subArg) showMemberDetail(+subArg, classOptions => classOpts(classOptions), patch); } else if (cur === "codes") { const d = await aapi("/api/admin/signup-codes"); body.innerHTML = `

    새 가입 코드

    ${noClassWarn}

    수업 시간에 학생들에게 알려 줄 코드입니다. 코드를 넣고 가입하면 바로 회원이 되어 지정한 반에 들어갑니다. 코드를 비워 두면 자동으로 만듭니다(예: K7P-2QX).

    가입 코드 ${d.items.length}

    ${d.items.length ? `
    ${d.items.map((k) => ``).join("")}
    코드반사용만료일메모상태
    ${esc(k.code)} ${k.uses}${k.max_uses ? ` / ${k.max_uses}` : ""}
    ` : empty("아직 가입 코드가 없습니다. 위에서 만들어 학생들에게 알려 주세요.")}
    `; $("#code-new").addEventListener("submit", async (e) => { e.preventDefault(); const f = e.target; const b = { code: f.code.value.trim() || null, class_id: +f.class_id.value || null, note: f.note.value.trim() || null, max_uses: +f.max_uses.value || null, expires_at: f.expires_at.value || null, active: true }; try { const r = await aapi("/api/admin/signup-codes", { method: "POST", body: b }); toast(`가입 코드 ${r.code} 를 만들었습니다.`); reload(); } catch (err) { fail(err); } }); $$("tr[data-code]", body).forEach((tr) => { const id = tr.dataset.code; const k = d.items.find((x) => String(x.code_id) === id); const g = (f) => $(`[data-f="${f}"]`, tr); $("[data-copy]", tr).addEventListener("click", async (e) => { try { await navigator.clipboard.writeText(e.target.dataset.copy); toast("복사했습니다."); } catch { toast(e.target.dataset.copy); } }); $("[data-save]", tr).addEventListener("click", async () => { const b = { class_id: +g("class_id").value || null, note: g("note").value.trim() || null, active: g("active").checked, max_uses: k.max_uses, expires_at: g("expires_at").value || null }; try { await aapi(`/api/admin/signup-codes/${id}`, { method: "PUT", body: b }); toast("저장했습니다."); reload(); } catch (e) { fail(e); } }); $("[data-del]", tr).addEventListener("click", async () => { if (!confirm("이 코드를 삭제할까요? 이미 가입한 회원은 그대로 남습니다.")) return; try { await aapi(`/api/admin/signup-codes/${id}`, { method: "DELETE" }); toast("삭제했습니다."); reload(); } catch (e) { fail(e); } }); }); } else if (cur === "classes") { body.innerHTML = `
    ${classes.length ? `
    ${classes.map((k) => ``).join("")}
    반메모회원푼 문제정답률
    ${k.members}${k.attempts}${rate(k.correct, k.attempts)} 학습 현황
    ` : empty("반이 없습니다.")}
    `; $("#class-new").addEventListener("submit", async (e) => { e.preventDefault(); const f = e.target; try { await aapi("/api/admin/classes", { method: "POST", body: { name: f.name.value.trim(), note: f.note.value.trim() || null } }); toast("반을 만들었습니다."); reload(); } catch (err) { fail(err); } }); $$("tr[data-cid]", body).forEach((tr) => { const cid = tr.dataset.cid; $("[data-csave]", tr).addEventListener("click", async () => { try { await aapi(`/api/admin/classes/${cid}`, { method: "PUT", body: { name: $("[data-cname]", tr).value.trim(), note: $("[data-cnote]", tr).value.trim() || null } }); toast("저장했습니다."); } catch (e) { fail(e); } }); $("[data-cdel]", tr).addEventListener("click", async () => { if (!confirm("이 반을 삭제할까요?")) return; try { await aapi(`/api/admin/classes/${cid}`, { method: "DELETE" }); toast("삭제했습니다."); reload(); } catch (e) { fail(e); } }); }); } else if (cur === "stats") { const cid = subArg || ""; body.innerHTML = `
    ${loading()}
    `; $("#st-class").addEventListener("change", (e) => (location.hash = `members/stats/${e.target.value}`)); const d = await aapi(`/api/admin/class-stats${cid ? `?class_id=${cid}` : ""}`); $("#st-body").innerHTML = `

    단원별 정답률

    ${d.by_galaxy.length ? d.by_galaxy.map((g) => { const p = pct(g.correct, g.attempts); return `
    ${esc(g.name)}${g.correct}/${g.attempts} · ${p}%
    `; }).join("") : empty("아직 푼 문제가 없습니다.")}

    많이 틀린 용어

    ${d.wrong_terms.length ? `` : empty("틀린 용어가 없습니다.")}
    `; } }; async function showMemberDetail(uid, _classOpts, patch) { const box = $("#mem-detail"); box.hidden = false; box.innerHTML = loading(); let u; try { u = await aapi(`/api/admin/members/${uid}`); } catch (e) { box.innerHTML = ""; fail(e); return; } const total = u.by_galaxy.reduce((a, g) => a + g.attempts, 0), right = u.by_galaxy.reduce((a, g) => a + g.correct, 0); box.innerHTML = `

    ${esc(u.name)} ${esc(u.username)}${u.student_no ? " · " + esc(u.student_no) : ""}

    ${badge(u.status, MEMBER_STATUS).replace(`badge ${u.status}`, `badge ${MEMBER_BADGE[u.status]}`)} 닫기

    반: ${esc(u.class_name || "미지정")} · 가입 코드: ${esc(u.signup_code || "-")} · 가입 ${esc(fmtDate(u.created_at))} · 승인 ${esc(fmtDate(u.approved_at) || "-")} (${esc(u.approved_by || "")}) · 최근 로그인 ${esc(fmtDate(u.last_login) || "-")}

    ${u.note ? `

    남긴 말: ${esc(u.note)}

    ` : ""}

    ${total}문제 중 ${right}개 정답 (${total ? Math.round((right / total) * 100) : 0}%)

    단원별

    ${u.by_galaxy.length ? u.by_galaxy.map((g) => { const p = pct(g.correct, g.attempts); return `
    ${esc(g.name)}${g.correct}/${g.attempts}
    `; }).join("") : empty("푼 문제가 없습니다.")}

    최근 학습일

    ${u.days.length ? `` : empty("기록 없음")}

    자주 틀린 용어

    ${u.wrong_terms.length ? `` : empty("없음")}

    최근 풀이

    ${u.recent.length ? `` : empty("없음")}
    ${u.status === "approved" ? `` : ""}
    `; $("[data-reset]", box).addEventListener("click", () => { const pw = prompt("새 비밀번호 (6자 이상). 학생에게 직접 알려 주세요."); if (pw) patch(uid, { new_password: pw }, "비밀번호를 바꿨습니다."); }); $("[data-disable]", box)?.addEventListener("click", () => confirm("이 회원의 사용을 중지할까요?") && patch(uid, { status: "disabled" }, "사용을 중지했습니다.")); $("[data-del]", box).addEventListener("click", async () => { if (!confirm("회원을 삭제할까요? 다시 로그인할 수 없습니다.")) return; try { await aapi(`/api/admin/members/${uid}`, { method: "DELETE" }); toast("삭제했습니다."); location.hash = "members/list"; } catch (e) { fail(e); } }); box.scrollIntoView({ behavior: "smooth", block: "start" }); } // ---------------------------------------------------------------- 문제 (직접 출제) VIEWS.codes = async () => VIEWS.members("codes"); /** 대시보드 상단: 반을 골라 바로 가입 코드 만들기 + 사용 중인 코드 */ async function renderQuickCode(box) { if (!box) return; let classes, codes; try { [classes, codes] = await Promise.all([aapi("/api/admin/classes"), aapi("/api/admin/signup-codes")]); } catch (e) { box.innerHTML = ""; fail(e); return; } const active = codes.items.filter((k) => k.active); const copyBtn = (c) => ``; box.innerHTML = `

    가입 코드

    코드 관리
    ${classes.items.length ? `
    ` : `
    먼저 반을 만들어 주세요.
    `} ${active.length ? `
    ${active.map((k) => `
    ${esc(k.code)}${esc(k.class_name || "")} · ${k.uses}${k.max_uses ? "/" + k.max_uses : ""}명${k.expires_at ? " · ~" + esc(k.expires_at) : ""}${copyBtn(k.code)}
    `).join("")}
    ` : `

    사용 중인 가입 코드가 없습니다.

    `}`; $("#qc-form")?.addEventListener("submit", async (e) => { e.preventDefault(); const f = e.target; try { const r = await aapi("/api/admin/signup-codes", { method: "POST", body: { class_id: +f.class_id.value, max_uses: +f.max_uses.value || null, expires_at: f.expires_at.value || null, active: true } }); toast(`가입 코드 ${r.code} 를 만들었습니다.`); await renderQuickCode(box); $("#qc-new", box).innerHTML = `
    새 코드 ${esc(r.code)} ${copyBtn(r.code)} 학생들에게 알려 주세요. /signup 에서 입력합니다.
    `; wireCopy(box); } catch (err) { fail(err); } }); $("#qc-class")?.addEventListener("submit", async (e) => { e.preventDefault(); try { await aapi("/api/admin/classes", { method: "POST", body: { name: e.target.name.value.trim() } }); toast("반을 만들었습니다."); renderQuickCode(box); } catch (err) { fail(err); } }); wireCopy(box); } function wireCopy(root) { $$("[data-copy]", root).forEach((b) => (b.onclick = async () => { try { await navigator.clipboard.writeText(b.dataset.copy); toast("복사했습니다."); } catch { toast(b.dataset.copy); } })); } VIEWS.questions = async (arg) => { const termId = arg || null; const d = await aapi("/api/admin/questions" + (termId ? `?term=${encodeURIComponent(termId)}` : "")); let termName = ""; if (termId) { try { termName = (await aapi(`/api/admin/terms/${encodeURIComponent(termId)}`)).name; } catch { /* 무시 */ } } view.innerHTML = `

    문제

    쉬운 설명이 있는 용어는 자동 객관식(뜻→용어, 용어→뜻)이 만들어집니다. 여기서는 직접 출제한 문제를 관리합니다. ‘검토 중’·‘확정’ 문제만 회원에게 나갑니다.

    ${termId ? `

    용어: ${esc(termName || termId)} · ${termLink(termId, "용어 편집")} · 전체 문제

    ` : ""}

    새 문제

    자동 문제 미리보기

    ${termId ? loading() : empty("관련 용어를 고르면 그 용어로 만들어지는 자동 문제를 보여 줍니다.")}

    직접 출제한 문제 ${d.items.length}

    ${d.items.length ? `
    ${d.items.map((q) => ``).join("")}
    #용어유형문제정답상태풀이·정답률
    ${q.question_id}${q.term_id ? termLink(q.term_id, q.term_name) : `-`} ${q.qtype === "ox" ? "OX" : "객관식"}${esc(q.prompt)} ${esc(q.qtype === "ox" ? ["O", "X"][q.answer] : (q.choices || [])[q.answer] || "")} ${badge(q.review_status)}${q.hidden ? ` 숨김` : ""}${q.attempts} · ${q.attempts ? pct(q.correct, q.attempts) + "%" : "-"}
    ` : empty("직접 출제한 문제가 없습니다.")}
    `; const f = $("#q-form"); let editing = null; const syncAnswer = (cur = 0) => { const ox = f.qtype.value === "ox"; $("[data-mcq]", f).hidden = ox; const ch = ox ? ["O", "X"] : f.choices.value.split("\n").map((x) => x.trim()).filter(Boolean); f.answer.innerHTML = ch.map((c, i) => opt(i, `${ox ? "" : i + 1 + ". "}${c}`, cur)).join("") || ``; }; f.qtype.addEventListener("change", () => syncAnswer()); f.choices.addEventListener("input", () => syncAnswer(+f.answer.value || 0)); syncAnswer(); const loadPreview = async (tid) => { const box = $("#q-preview"); if (!tid) { box.innerHTML = empty("관련 용어를 고르면 그 용어로 만들어지는 자동 문제를 보여 줍니다."); return; } box.innerHTML = loading(); try { const p = await aapi(`/api/admin/questions/preview?term=${encodeURIComponent(tid)}`); box.innerHTML = p.items.length ? p.items.map((q) => `

    ${esc(q.prompt)}

    ${q.quote ? `

    「${esc(q.quote)}」

    ` : ""}
      ${q.choices.map((c, i) => `${esc(c)}${i === q.answer ? " ✓" : ""}`).join("")}
    `).join("") : empty(p.reason || "자동 문제를 만들 수 없습니다."); } catch (e) { box.innerHTML = ""; fail(e); } }; if (termId) loadPreview(termId); // 용어 자동완성 const ac = $("#q-ac"); f.term_q.addEventListener("input", debounce(async () => { const q = f.term_q.value.trim(); f.term_id.value = ""; if (!q) { ac.hidden = true; loadPreview(null); return; } try { const r = await aapi(`/api/admin/terms?q=${encodeURIComponent(q)}&limit=10`); ac.innerHTML = r.items.map((t) => ``).join("") || `
    없음
    `; ac.hidden = false; } catch (e) { fail(e); } }, 200)); ac.addEventListener("click", (e) => { const b = e.target.closest("[data-tid]"); if (!b) return; f.term_id.value = b.dataset.tid; f.term_q.value = b.textContent.replace(/\s+\S+$/, "").trim(); ac.hidden = true; loadPreview(b.dataset.tid); }); const reset = () => { editing = null; f.reset(); f.term_id.value = termId || ""; f.term_q.value = termName; $("#q-form-title").textContent = "새 문제"; syncAnswer(); }; $("#q-reset").addEventListener("click", reset); f.addEventListener("submit", async (e) => { e.preventDefault(); const body = { term_id: f.term_id.value || null, qtype: f.qtype.value, prompt: f.prompt.value.trim(), choices: f.qtype.value === "mcq" ? f.choices.value.split("\n").map((x) => x.trim()).filter(Boolean) : [], answer: +f.answer.value, explanation: f.explanation.value.trim() || null, review_status: f.review_status.value, hidden: f.hidden.checked, }; try { if (editing) await aapi(`/api/admin/questions/${editing}`, { method: "PUT", body }); else await aapi("/api/admin/questions", { method: "POST", body }); toast("저장했습니다."); VIEWS.questions(termId); } catch (err) { fail(err); } }); $$("tr[data-qid]", view).forEach((tr) => { const q = d.items.find((x) => x.question_id === +tr.dataset.qid); $("[data-edit]", tr).addEventListener("click", () => { editing = q.question_id; $("#q-form-title").textContent = `문제 #${q.question_id} 수정`; f.term_id.value = q.term_id || ""; f.term_q.value = q.term_name || ""; f.qtype.value = q.qtype; f.prompt.value = q.prompt; f.choices.value = (q.choices || []).join("\n"); f.explanation.value = q.explanation || ""; f.review_status.value = q.review_status; f.hidden.checked = !!q.hidden; syncAnswer(q.answer); loadPreview(q.term_id); f.scrollIntoView({ behavior: "smooth", block: "start" }); }); $("[data-del]", tr).addEventListener("click", async () => { if (!confirm("이 문제를 삭제할까요?")) return; try { await aapi(`/api/admin/questions/${q.question_id}`, { method: "DELETE" }); toast("삭제했습니다."); VIEWS.questions(termId); } catch (e) { fail(e); } }); }); }; // ---------------------------------------------------------------- 시작 function syncTopbarHeight() { document.documentElement.style.setProperty("--topbar-h", `${$("#topbar").offsetHeight}px`); } window.addEventListener("resize", debounce(syncTopbarHeight, 100)); syncTopbarHeight(); (async function init() { try { const me = await api("/api/admin/me"); if (!me.name) return showLogin(); S.me = me.name; S.role = me.role; showApp(); route(); refreshFeedbackCount(); } catch (e) { showLogin(); toast(e.message, true); } })();