// 관리 화면: 로그인, 대시보드, 피드백, 용어·관계 검수, 은하·단원, 설정, 기록.
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) => `${esc(label)} `;
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 ``;
const from = total ? offset + 1 : 0, to = Math.min(total, offset + limit);
return ``;
}
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
? `${Object.entries(obj).map(([k, v]) => `${cls ? badge(k, labels) : esc(labels[k] || k || "(없음)")}${esc(v)} `).join("")} `
: `없음
`;
view.innerHTML = `
대시보드
파이프라인 생성: ${esc(fmtDate(s.meta.generated_at) || "—")} · DB 가져온 시각: ${esc(fmtDate(s.meta.seeded_at) || "—")}
용어 검수 상태
${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) => `
${esc(g.name)}
${esc(g.terms)} ${esc(g.confirmed || 0)}
${meter(g.confirmed || 0, g.terms)}${pct(g.confirmed || 0, g.terms)}%
`).join("")}
데이터 관리
파이프라인(data/atlas.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 = `${Object.entries(r).map(([k, v]) => `${esc(L[k] || k)} ${esc(v)} `).join("")} `;
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 = `
피드백
상태
${opt("open", "열린 것(접수+처리 중)", FF.status)}${opt("", "전체", FF.status)}${opts(FEEDBACK_STATUS_LABEL, FF.status)}
유형 ${opt("", "모든 유형", FF.kind)}${opts(KIND_LABEL, FF.kind)}
검색
`;
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) => `
${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)} `).join("")}
${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 = `
검색
은하 ${galaxyOptions(TF.galaxy)}
단원 ${sectionOptions(TF.galaxy, TF.section)}
검수 상태 ${opt("", "모든 상태", TF.status)}${TERM_STATUSES.map((k) => opt(k, STATUS_LABEL[k], TF.status)).join("")}
빠진 항목 ${opt("", "빠진 항목 무관", TF.missing)}${opt("easy", "쉬운 뜻 없음", TF.missing)}${opt("definition", "정의 없음", TF.missing)}${opt("section", "단원 추정", TF.missing)}
숨김 ${opt("0", "보이는 용어", TF.hidden)}${opt("1", "숨긴 용어", TF.hidden)}${opt("", "모두", TF.hidden)}
정렬 ${opt("name", "이름순", TF.sort)}${opt("updated", "최근 수정순", TF.sort)}${opt("status", "상태순", TF.sort)}
검토 중으로
확정으로
검수 전으로
숨기기
다시 보이기
선택 해제
${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 ? `${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, "학생 화면에서 보기")} ·
문제 `}
기본 정보
이름 *
종류 ${opts(KIND_OPTS, orig.kind)}
별칭 (쉼표나 줄바꿈으로 구분) ${esc(listToText(orig.aliases))}
검색어 (쉼표나 줄바꿈으로 구분) ${esc(listToText(orig.search_terms))}
은하 ${galaxyOptions(orig.galaxy_id, "(없음)")}
단원${t.section_guess ? ' 추정 ' : ""}
세부 항목
단원만 고르면 은하는 서버에서 단원에 맞춰 정해집니다.${t.section_guess ? " 이 용어의 단원은 자동으로 추정된 것이니 확인해 주세요." : ""}
출처 (색인 위치) 출처 추가
‘정의로 사용’을 누르면 해당 문장을 교과서 정의 칸에 옮기고 교재·쪽수를 맞춥니다.
`;
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) => `
${bookOptions(s.book_id)}
${esc(s.snippet ?? "")}
정의로 사용
삭제 `;
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 = `
관계
관계 추가
유형 ${opts(REL_TYPE_LABEL, "related_to")}
설명(라벨)
근거
추가
검색
검수 상태 ${opt("", "모든 상태", RF.status)}${REL_STATUSES.map((k) => opt(k, STATUS_LABEL[k], RF.status)).join("")}
유형 ${opt("", "모든 유형", RF.type)}${opts(REL_TYPE_LABEL, RF.type)}
생성 방법 ${opt("", "모든 방법", RF.method)}${opts(METHOD_LABEL, RF.method)}
은하 ${galaxyOptions(RF.galaxy)}
확정
검토 중
반려
숨기기
다시 보이기
선택 해제
${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 ? `${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 ?? "")}
${galaxyOptions(s.galaxy_id, "(없음)")}
${s.edited ? '✎ ' : ""}저장 `;
const secTable = (list) => list?.length ? `교재 단원 제목 쉬운 보조 설명 은하 동작
${list.map(secRow).join("")}
` : `주 교재 단원이 없습니다.
`;
view.innerHTML = `
은하·단원 은하는 큰 단원(장), 단원은 주 교재의 절입니다. 고친 항목은 파이프라인 결과를 다시 가져와도 유지됩니다.
${d.galaxies.map((g) => `
`).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 = `
설정
설정 저장
`;
$("#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 = `
기록
대상 ${opt("", "모든 대상", AF.entity)}${opts(ENTITY_LABEL, AF.entity)}
${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 ? `
시각 검수자 동작 대상 ID 이전 이후
${d.items.map((a) => `
${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, "보기")} `).join("")}
${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 = "반 선택") => `${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 = `
회원 관리 학생은 수업에서 알려 준 가입 코드 를 넣으면 바로 회원이 되어 사례와 문제를 볼 수 있습니다. 코드마다 반이 정해져 있어 그 반으로 들어갑니다.
${SUBS.map(([k, l]) => `${l} `).join("")}
`;
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]}`)} `}
${classOpts(u.class_id)}
${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 = `
가입 코드 ${d.items.length}
${d.items.length ? `` : 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 = ``;
$("#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 = `반 ${classOpts(cid, "전체")}
${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 ? `
${d.wrong_terms.map((t) => `${termLink(t.term_id, t.name)} ${t.wrong}/${t.attempts} 오답 `).join("")} ` : 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 ? `
${u.days.map((d) => `${esc(d.day)} · ${d.attempts}문제 · 정답 ${d.correct} `).join("")} ` : empty("기록 없음")}
자주 틀린 용어 ${u.wrong_terms.length ? `
${u.wrong_terms.map((t) => `${termLink(t.term_id, t.name)} ${t.wrong}회 `).join("")} ` : empty("없음")}
최근 풀이 ${u.recent.length ? `
${u.recent.map((r) => `${r.correct ? "⭕" : "❌"} ${esc(r.term_name || "-")} ${esc(fmtDate(r.created_at))} `).join("")} ` : 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 ? `
${classes.items.map((k) => opt(k.class_id, k.name, "")).join("")}
코드 만들기
`
: `먼저 반을 만들어 주세요. 반 만들기 `}
${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) => `${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) + "%" : "-"}
수정 삭제 `).join("")}
` : 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) => `${esc(t.name)} ${esc(S.galaxyById[t.galaxy_id]?.name || "")} `).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);
}
})();