- Introduced common.js for shared functions including API calls, error reporting, and UI helpers. - Implemented feedback.js for the feedback reporting page, including form handling and displaying feedback status. - Added layout.js for managing galaxy and section layouts in the visualization. - Created login.js for user authentication with session management. - Developed search.js for term searching based on user input and synonyms. - Implemented signup.js for user registration with validation and immediate login.
1594 lines
102 KiB
JavaScript
1594 lines
102 KiB
JavaScript
// 관리 화면: 로그인, 대시보드, 피드백, 용어·관계 검수, 은하·단원, 설정, 기록.
|
||
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 ? `<span class="badge ${esc(st)}">${esc(labels[st] || st)}</span>` : "";
|
||
const opt = (v, label, cur) => `<option value="${esc(v)}"${String(cur ?? "") === String(v) ? " selected" : ""}>${esc(label)}</option>`;
|
||
const opts = (obj, cur) => Object.entries(obj).map(([k, l]) => opt(k, l, cur)).join("");
|
||
const studentLink = (id, text = "학생 화면") => `<a href="/#/t/${encodeURIComponent(id)}" target="_blank" rel="noopener">${esc(text)}</a>`;
|
||
const termLink = (id, name) => `<a href="#term/${encodeURIComponent(id)}">${esc(name || id)}</a>`;
|
||
const pct = (a, b) => (b ? Math.round((a / b) * 1000) / 10 : 0);
|
||
const loading = (msg = "불러오는 중…") => `<div class="state">${esc(msg)}</div>`;
|
||
const empty = (msg) => `<div class="state">${esc(msg)}</div>`;
|
||
|
||
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 `<span class="faint">—</span>`;
|
||
return `<details class="json"${open ? " open" : ""}><summary>${esc(summary)}</summary><pre>${esc(s)}</pre></details>`;
|
||
}
|
||
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 `<div class="pager faint">총 ${total}건</div>`;
|
||
const from = total ? offset + 1 : 0, to = Math.min(total, offset + limit);
|
||
return `<div class="pager"><span class="faint">총 ${total}건 중 ${from}–${to}</span>
|
||
<button type="button" class="btn small" data-page="prev"${offset <= 0 ? " disabled" : ""}>이전</button>
|
||
<button type="button" class="btn small" data-page="next"${to >= total ? " disabled" : ""}>다음</button></div>`;
|
||
}
|
||
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 = `<div class="ac"><input class="input" type="text" autocomplete="off" role="combobox" aria-autocomplete="list"
|
||
aria-expanded="false" aria-controls="${id}" aria-label="${esc(label)}" placeholder="${esc(placeholder)}">
|
||
<ul class="ac-list" id="${id}" role="listbox" hidden></ul></div>`;
|
||
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) => `<li id="${id}-${i}" role="option" data-i="${i}" aria-selected="false">
|
||
<span>${esc(t.name)}</span>${t.hidden ? ' <span class="badge">숨김</span>' : ""}
|
||
<span class="ac-sub">${esc(galaxyName(t.galaxy_id))} · ${esc(t.term_id)}</span></li>`).join("")
|
||
: `<li class="ac-empty" role="option" aria-disabled="true">찾는 용어가 없습니다.</li>`;
|
||
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 = `<div class="state error">화면을 불러오지 못했습니다: ${esc(e.message)}</div>`;
|
||
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) => `<div class="meter" role="img" aria-label="${pct(n, d)}%"><span style="width:${Math.min(100, pct(n, d))}%"></span></div>`;
|
||
const kv = (obj, labels, cls) => Object.keys(obj).length
|
||
? `<ul class="kv">${Object.entries(obj).map(([k, v]) => `<li>${cls ? badge(k, labels) : esc(labels[k] || k || "(없음)")}<b>${esc(v)}</b></li>`).join("")}</ul>`
|
||
: `<p class="faint">없음</p>`;
|
||
view.innerHTML = `<div id="dash">
|
||
<div class="page-head">
|
||
<h1>대시보드</h1>
|
||
<p class="faint">파이프라인 생성: ${esc(fmtDate(s.meta.generated_at) || "—")} · DB 가져온 시각: ${esc(fmtDate(s.meta.seeded_at) || "—")}</p>
|
||
</div>
|
||
<section class="card quick-code" id="quick-code">${loading()}</section>
|
||
<div class="cards">
|
||
<section class="card stat"><h2>용어 검수 상태</h2>
|
||
<div class="big">${tot.toLocaleString()}<small>개 (숨김 ${esc(s.terms_hidden)})</small></div>
|
||
<ul class="kv">${TERM_STATUSES.map((k) => `<li><a href="#terms" data-goto-terms='{"status":"${k}"}'>${badge(k)}</a><b>${(s.terms[k] || 0).toLocaleString()}</b></li>`).join("")}</ul>
|
||
${meter(s.terms.confirmed || 0, tot)}<p class="faint small">확정 ${pct(s.terms.confirmed || 0, tot)}%</p>
|
||
</section>
|
||
<section class="card stat"><h2>쉬운 뜻</h2>
|
||
<div class="big">${pct(s.terms_easy, tot)}<small>%</small></div>
|
||
${meter(s.terms_easy, tot)}<p class="faint small">${s.terms_easy.toLocaleString()} / ${tot.toLocaleString()}개 · <a href="#terms" data-goto-terms='{"missing":"easy"}'>빠진 용어 보기</a></p>
|
||
</section>
|
||
<section class="card stat"><h2>교과서 정의</h2>
|
||
<div class="big">${pct(s.terms_definition, tot)}<small>%</small></div>
|
||
${meter(s.terms_definition, tot)}<p class="faint small">${s.terms_definition.toLocaleString()} / ${tot.toLocaleString()}개 · <a href="#terms" data-goto-terms='{"missing":"definition"}'>빠진 용어 보기</a></p>
|
||
</section>
|
||
<section class="card stat"><h2>단원 추정</h2>
|
||
<div class="big">${s.terms_section_guess.toLocaleString()}<small>개</small></div>
|
||
<p class="faint small">목차에서 직접 찾지 못해 단원을 추정한 용어입니다. · <a href="#terms" data-goto-terms='{"missing":"section"}'>확인하기</a></p>
|
||
</section>
|
||
<section class="card stat"><h2>관계 검수 상태</h2>
|
||
<div class="big">${relTot.toLocaleString()}<small>개</small></div>
|
||
${kv(s.relations, STATUS_LABEL, true)}
|
||
</section>
|
||
<section class="card stat"><h2>관계 생성 방법</h2>${kv(s.relations_by_method, METHOD_LABEL)}</section>
|
||
<section class="card stat"><h2>열린 피드백</h2>
|
||
<div class="big">${openFb}<small>건</small></div>
|
||
${kv(s.feedback_open_by_kind, KIND_LABEL)}
|
||
<p class="small"><a href="#feedback">피드백 처리하러 가기</a></p>
|
||
</section>
|
||
</div>
|
||
<section class="card">
|
||
<h2>은하별 검수 현황</h2>
|
||
<div class="table-wrap"><table class="data">
|
||
<thead><tr><th scope="col">은하</th><th scope="col" class="num">용어</th><th scope="col" class="num">확정</th><th scope="col" style="width:40%">확정 비율</th></tr></thead>
|
||
<tbody>${s.by_galaxy.map((g) => `<tr>
|
||
<td><span class="dot" style="background:${esc(S.galaxyById[g.galaxy_id]?.color || "#888")}"></span><a href="#terms" data-goto-terms='{"galaxy":"${esc(g.galaxy_id)}"}'>${esc(g.name)}</a></td>
|
||
<td class="num">${esc(g.terms)}</td><td class="num">${esc(g.confirmed || 0)}</td>
|
||
<td><div class="row nowrap">${meter(g.confirmed || 0, g.terms)}<span class="faint small">${pct(g.confirmed || 0, g.terms)}%</span></div></td></tr>`).join("")}</tbody>
|
||
</table></div>
|
||
</section>
|
||
<section class="card">
|
||
<h2>데이터 관리</h2>
|
||
<p class="muted">파이프라인(<code>data/atlas.json</code>)을 다시 돌린 뒤 결과를 가져옵니다. 사람이 고친 용어·관계·은하·단원은 그대로 유지되고, 고치지 않은 자동 항목만 새 결과로 바뀝니다.</p>
|
||
<div class="row">
|
||
<button type="button" class="btn primary" id="reseed">파이프라인 결과 다시 가져오기</button>
|
||
<a class="btn" href="/api/admin/export" download>전체 내보내기(JSON)</a>
|
||
</div>
|
||
<div id="reseed-result"></div>
|
||
</section></div>`;
|
||
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 = `<ul class="kv cols">${Object.entries(r).map(([k, v]) => `<li><span>${esc(L[k] || k)}</span><b>${esc(v)}</b></li>`).join("")}</ul>`;
|
||
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 = `
|
||
<div class="page-head"><h1>피드백</h1></div>
|
||
<div class="filters" role="search">
|
||
<label><span class="sr-only">상태</span><select class="input" id="ff-status">
|
||
${opt("open", "열린 것(접수+처리 중)", FF.status)}${opt("", "전체", FF.status)}${opts(FEEDBACK_STATUS_LABEL, FF.status)}</select></label>
|
||
<label><span class="sr-only">유형</span><select class="input" id="ff-kind">${opt("", "모든 유형", FF.kind)}${opts(KIND_LABEL, FF.kind)}</select></label>
|
||
<label class="grow"><span class="sr-only">검색</span><input class="input" id="ff-q" type="search" placeholder="내용·용어·메모 검색" value="${esc(FF.q)}"></label>
|
||
</div>
|
||
<div class="split" id="fb-split">
|
||
<div class="split-main"><div id="ff-list">${loading()}</div></div>
|
||
<aside class="split-side" id="fb-detail" aria-label="피드백 상세" hidden></aside>
|
||
</div>`;
|
||
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 ? `<div class="table-wrap"><table class="data clickable">
|
||
<thead><tr><th scope="col">#</th><th scope="col">접수</th><th scope="col">유형</th><th scope="col">대상</th><th scope="col">내용</th><th scope="col">상태</th></tr></thead>
|
||
<tbody>${d.items.map((f) => `<tr data-id="${f.id}" tabindex="0"${String(f.id) === cur ? ' aria-selected="true" class="sel"' : ""}>
|
||
<td class="num">${f.id}</td>
|
||
<td class="nowrap small">${esc(fmtDate(f.created_at))}</td>
|
||
<td><span class="small">${esc(KIND_LABEL[f.kind] || f.kind)}</span><br><span class="faint small">${esc(f.category || "")}</span></td>
|
||
<td>${fbTarget(f)}</td>
|
||
<td class="msg-cell"><span class="clamp2">${esc(f.message)}</span>${f.occurrences > 1 ? ` <span class="badge warnish">×${f.occurrences}</span>` : ""}</td>
|
||
<td>${badge(f.status, FEEDBACK_STATUS_LABEL)}</td></tr>`).join("")}</tbody></table></div>${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)} <span class="small">${studentLink(f.target_id, "↗")}</span>`;
|
||
}
|
||
if (f.target_type === "relation" && f.target_id) return `<span class="small">관계 <code>${esc(f.target_id)}</code></span>${f.target_label ? `<br><span class="faint small">${esc(f.target_label)}</span>` : ""}`;
|
||
return f.target_label ? esc(f.target_label) : `<span class="faint">—</span>`;
|
||
}
|
||
|
||
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 = `<div class="card">${empty(`#${id} 피드백을 찾을 수 없습니다.`)}</div>`; 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 = `<div class="card detail">
|
||
<div class="row detail-head"><h2 class="grow">#${f.id} ${esc(f.category || KIND_LABEL[f.kind] || "")}</h2>
|
||
<a class="btn small ghost" href="#feedback" aria-label="상세 닫기">닫기</a></div>
|
||
<p class="row small">${badge(f.status, FEEDBACK_STATUS_LABEL)} <span class="faint">${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)}` : ""}</span>
|
||
${f.occurrences > 1 ? `<span class="badge warnish">${f.occurrences}회 발생</span>` : ""}</p>
|
||
<dl class="dl">
|
||
<dt>대상</dt><dd>${fbTarget(f)}${isTerm ? ` <a class="btn small" href="#term/${encodeURIComponent(f.target_id)}">용어 편집 열기</a>` : ""}</dd>
|
||
<dt>내용</dt><dd class="pre">${esc(f.message)}</dd>
|
||
${f.suggestion ? `<dt>제안</dt><dd class="pre">${esc(f.suggestion)}</dd>` : ""}
|
||
${f.page_url ? `<dt>화면 주소</dt><dd class="break"><a href="${esc(safeUrl(f.page_url))}" target="_blank" rel="noopener noreferrer">${esc(f.page_url)}</a></dd>` : ""}
|
||
${f.reporter || f.contact ? `<dt>신고자</dt><dd>${esc(f.reporter || "(이름 없음)")}${f.contact ? ` · ${esc(f.contact)}` : ""}</dd>` : ""}
|
||
${f.user_agent ? `<dt>브라우저</dt><dd class="small faint break">${esc(f.user_agent)}</dd>` : ""}
|
||
${f.context ? `<dt>상세 정보</dt><dd>${jsonBlock(f.context, "오류 상세(스택 등) 펼치기", (f.kind || "").endsWith("_error"))}</dd>` : ""}
|
||
</dl>
|
||
<form id="fb-edit">
|
||
<label class="field"><span>처리 상태</span><select class="input" name="status">${opts(FEEDBACK_STATUS_LABEL, f.status)}</select></label>
|
||
<label class="field"><span>처리 결과 (공개 목록에 표시됩니다)</span><textarea class="input" name="resolution" rows="3">${esc(f.resolution || "")}</textarea></label>
|
||
<label class="field"><span>내부 메모 (관리자만 봅니다)</span><textarea class="input" name="admin_note" rows="3">${esc(f.admin_note || "")}</textarea></label>
|
||
<div class="row">
|
||
<button class="btn primary" type="submit">저장</button>
|
||
<span class="faint small">빠른 처리:</span>
|
||
<button type="button" class="btn small" data-quick="in_progress">처리 중</button>
|
||
<button type="button" class="btn small" data-quick="resolved">반영 완료</button>
|
||
<button type="button" class="btn small" data-quick="rejected">반영 안 함</button>
|
||
<button type="button" class="btn small" data-quick="duplicate">중복</button>
|
||
</div>
|
||
<div class="row danger-zone"><button type="button" class="btn small danger" id="fb-del">삭제</button></div>
|
||
</form></div>`;
|
||
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 = `
|
||
<div class="page-head row"><h1 class="grow">용어</h1><a class="btn primary" href="#term/new">새 용어</a></div>
|
||
<div class="filters" role="search">
|
||
<label class="grow"><span class="sr-only">검색</span><input class="input" id="tf-q" type="search" placeholder="이름·별칭·정의·쉬운 뜻·ID" value="${esc(TF.q)}"></label>
|
||
<label><span class="sr-only">은하</span><select class="input" id="tf-galaxy">${galaxyOptions(TF.galaxy)}</select></label>
|
||
<label><span class="sr-only">단원</span><select class="input" id="tf-section">${sectionOptions(TF.galaxy, TF.section)}</select></label>
|
||
<label><span class="sr-only">검수 상태</span><select class="input" id="tf-status">${opt("", "모든 상태", TF.status)}${TERM_STATUSES.map((k) => opt(k, STATUS_LABEL[k], TF.status)).join("")}</select></label>
|
||
<label><span class="sr-only">빠진 항목</span><select class="input" id="tf-missing">${opt("", "빠진 항목 무관", TF.missing)}${opt("easy", "쉬운 뜻 없음", TF.missing)}${opt("definition", "정의 없음", TF.missing)}${opt("section", "단원 추정", TF.missing)}</select></label>
|
||
<label><span class="sr-only">숨김</span><select class="input" id="tf-hidden">${opt("0", "보이는 용어", TF.hidden)}${opt("1", "숨긴 용어", TF.hidden)}${opt("", "모두", TF.hidden)}</select></label>
|
||
<label><span class="sr-only">정렬</span><select class="input" id="tf-sort">${opt("name", "이름순", TF.sort)}${opt("updated", "최근 수정순", TF.sort)}${opt("status", "상태순", TF.sort)}</select></label>
|
||
</div>
|
||
<div class="bulkbar" id="tf-bulk" hidden>
|
||
<span id="tf-selcount"></span>
|
||
<button type="button" class="btn small" data-bulk='{"review_status":"review"}'>검토 중으로</button>
|
||
<button type="button" class="btn small" data-bulk='{"review_status":"confirmed"}'>확정으로</button>
|
||
<button type="button" class="btn small" data-bulk='{"review_status":"draft"}'>검수 전으로</button>
|
||
<button type="button" class="btn small" data-bulk='{"hidden":true}'>숨기기</button>
|
||
<button type="button" class="btn small" data-bulk='{"hidden":false}'>다시 보이기</button>
|
||
<button type="button" class="btn small ghost" id="tf-clear">선택 해제</button>
|
||
</div>
|
||
<div id="tf-list">${loading()}</div>`;
|
||
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 ? `<div class="table-wrap"><table class="data">
|
||
<thead><tr><th scope="col" class="chk"><input type="checkbox" id="tf-all" aria-label="이 페이지 모두 선택"></th>
|
||
<th scope="col">이름</th><th scope="col">은하</th><th scope="col">단원</th><th scope="col">상태</th>
|
||
<th scope="col" class="c" title="쉬운 뜻">쉬운 뜻</th><th scope="col" class="c" title="교과서 정의">정의</th><th scope="col">수정</th></tr></thead>
|
||
<tbody>${d.items.map((t) => {
|
||
const sec = S.tocById[t.section_id];
|
||
return `<tr${t.hidden ? ' class="dim"' : ""}>
|
||
<td class="chk"><input type="checkbox" data-sel="${esc(t.term_id)}" aria-label="${esc(t.name)} 선택"${TF.selected.has(t.term_id) ? " checked" : ""}></td>
|
||
<td>${termLink(t.term_id, t.name)}
|
||
${t.kind === "person" ? '<span class="hint">인물</span>' : ""}${t.lang === "en" ? '<span class="hint">영어</span>' : ""}${t.origin === "manual" ? '<span class="hint">직접 추가</span>' : ""}${t.hidden ? '<span class="badge">숨김</span>' : ""}
|
||
${t.aliases?.length ? `<div class="faint small clamp1">${esc(t.aliases.join(", "))}</div>` : ""}</td>
|
||
<td class="small">${esc(galaxyName(t.galaxy_id))}</td>
|
||
<td class="small">${sec ? esc(secLabel(sec)) : '<span class="faint">—</span>'}${t.section_guess ? ' <span class="badge warnish" title="목차에서 직접 찾지 못해 추정한 단원">추정</span>' : ""}</td>
|
||
<td>${badge(t.review_status)}</td>
|
||
<td class="c">${t.has_easy ? '<span class="ok" aria-label="있음">✓</span>' : '<span class="faint" aria-label="없음">·</span>'}</td>
|
||
<td class="c">${t.has_def ? '<span class="ok" aria-label="있음">✓</span>' : '<span class="faint" aria-label="없음">·</span>'}</td>
|
||
<td class="small nowrap">${t.edited ? '<span class="edited" title="사람이 고친 용어">✎</span> ' : ""}${t.reviewer ? esc(t.reviewer) + "<br>" : ""}<span class="faint">${esc(fmtDate(t.updated_at))}</span></td></tr>`;
|
||
}).join("")}</tbody></table></div>${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 = `
|
||
<div class="page-head row">
|
||
<a href="#terms" class="btn small ghost">← 용어 목록</a>
|
||
<h1 class="grow">${creating ? "새 용어" : esc(t.name)} ${creating ? "" : badge(t.review_status)}${t.hidden ? ' <span class="badge">숨김</span>' : ""}</h1>
|
||
${creating ? "" : `<span class="faint small">${esc(t.term_id)}</span> ${studentLink(t.term_id, "학생 화면에서 보기")} · <a href="#questions/${encodeURIComponent(t.term_id)}">문제</a>`}
|
||
</div>
|
||
<div class="editor">
|
||
<form id="te-form" class="editor-main" novalidate>
|
||
<section class="card">
|
||
<h2>기본 정보</h2>
|
||
<div class="grid2">
|
||
<label class="field"><span>이름 *</span><input class="input" name="name" required maxlength="120" value="${esc(orig.name)}"></label>
|
||
<label class="field"><span>종류</span><select class="input" name="kind">${opts(KIND_OPTS, orig.kind)}</select></label>
|
||
<label class="field"><span>별칭 (쉼표나 줄바꿈으로 구분)</span><textarea class="input short" name="aliases" rows="2">${esc(listToText(orig.aliases))}</textarea></label>
|
||
<label class="field"><span>검색어 (쉼표나 줄바꿈으로 구분)</span><textarea class="input short" name="search_terms" rows="2">${esc(listToText(orig.search_terms))}</textarea></label>
|
||
</div>
|
||
<div class="grid3">
|
||
<label class="field"><span>은하</span><select class="input" name="galaxy_id">${galaxyOptions(orig.galaxy_id, "(없음)")}</select></label>
|
||
<label class="field"><span>단원${t.section_guess ? ' <span class="badge warnish">추정</span>' : ""}</span><select class="input" name="section_id"></select></label>
|
||
<label class="field"><span>세부 항목</span><select class="input" name="item_id"></select></label>
|
||
</div>
|
||
<p class="faint small">단원만 고르면 은하는 서버에서 단원에 맞춰 정해집니다.${t.section_guess ? " 이 용어의 단원은 자동으로 추정된 것이니 확인해 주세요." : ""}</p>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>설명</h2>
|
||
<label class="field"><span>쉬운 뜻 <span class="method">${esc(METHOD_NOTE[t.easy_method] || METHOD_LABEL[t.easy_method] || (t.easy_method ? t.easy_method : "없음"))}</span></span>
|
||
<textarea class="input" name="easy" rows="3">${esc(orig.easy || "")}</textarea></label>
|
||
<label class="field"><span>사례</span><textarea class="input" name="example" rows="3">${esc(orig.example || "")}</textarea></label>
|
||
<label class="field"><span>교과서 정의 — 원문 그대로 <span class="method">${esc(METHOD_NOTE[t.definition_method] || METHOD_LABEL[t.definition_method] || (t.definition_method ? t.definition_method : "없음"))}</span></span>
|
||
<textarea class="input" name="definition_text" rows="4">${esc(orig.definition_text || "")}</textarea></label>
|
||
<div class="grid2">
|
||
<label class="field"><span>정의 출처 교재</span><select class="input" name="definition_book">${bookOptions(orig.definition_book, "(없음)")}</select></label>
|
||
<label class="field"><span>정의 쪽수</span><input class="input" name="definition_page" type="number" min="1" inputmode="numeric" value="${esc(orig.definition_page ?? "")}"></label>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<div class="row"><h2 class="grow">출처 (색인 위치)</h2><button type="button" class="btn small" id="src-add">출처 추가</button></div>
|
||
<p class="faint small">‘정의로 사용’을 누르면 해당 문장을 교과서 정의 칸에 옮기고 교재·쪽수를 맞춥니다.</p>
|
||
<div class="table-wrap"><table class="data sources"><thead><tr><th scope="col">교재</th><th scope="col">쪽</th><th scope="col">PDF쪽</th><th scope="col">위치·문장</th><th scope="col"><span class="sr-only">동작</span></th></tr></thead>
|
||
<tbody id="src-body"></tbody></table></div>
|
||
</section>
|
||
|
||
<section class="card">
|
||
<h2>검수</h2>
|
||
<fieldset class="radios"><legend>검수 상태</legend>
|
||
${TERM_STATUSES.map((k) => `<label><input type="radio" name="review_status" value="${k}"${orig.review_status === k ? " checked" : ""}> ${badge(k)}</label>`).join("")}
|
||
</fieldset>
|
||
<label class="check"><input type="checkbox" name="hidden"${orig.hidden ? " checked" : ""}> 학생 화면에서 숨기기</label>
|
||
<label class="field"><span>메모</span><textarea class="input short" name="note" rows="2">${esc(orig.note || "")}</textarea></label>
|
||
<div class="row savebar">
|
||
<button type="submit" class="btn primary" id="te-save">${creating ? "용어 만들기" : "저장"}</button>
|
||
<span class="faint small" id="te-dirty" aria-live="polite"></span>
|
||
${creating ? "" : `<span class="faint small">마지막 수정: ${esc(t.reviewer || "자동")} · ${esc(fmtDate(t.updated_at))}</span>`}
|
||
</div>
|
||
</section>
|
||
</form>
|
||
|
||
<aside class="editor-side">
|
||
${creating ? `<section class="card"><p class="muted">용어를 만든 뒤 관계·합치기를 할 수 있습니다.</p></section>` : `
|
||
<section class="card">
|
||
<h2>관계 <span class="faint small">(${t.relations.length})</span></h2>
|
||
<div id="te-rels"></div>
|
||
<details class="addbox"><summary>관계 추가</summary>
|
||
<form id="rel-add" class="stack">
|
||
<div class="field"><span class="lbl">상대 용어</span><div id="rel-add-picker"></div></div>
|
||
<div class="grid2">
|
||
<label class="field"><span>유형</span><select class="input" name="type">${opts(REL_TYPE_LABEL, "related_to")}</select></label>
|
||
<label class="field"><span>방향</span><select class="input" name="dir">${opt("out", "이 용어 → 상대", "out")}${opt("in", "상대 → 이 용어", "out")}</select></label>
|
||
</div>
|
||
<label class="field"><span>설명(라벨)</span><input class="input" name="label" maxlength="200"></label>
|
||
<label class="field"><span>근거</span><textarea class="input short" name="reason" rows="2" maxlength="2000"></textarea></label>
|
||
<button class="btn primary small" type="submit">관계 추가</button>
|
||
</form>
|
||
</details>
|
||
</section>
|
||
<section class="card">
|
||
<h2>다른 용어로 합치기</h2>
|
||
<p class="faint small">중복 용어일 때 사용합니다. 이 용어의 출처·관계·별칭을 대상 용어로 옮기고, 이 용어는 숨깁니다.</p>
|
||
<div id="merge-picker"></div>
|
||
<button type="button" class="btn small danger" id="merge-btn">합치기</button>
|
||
</section>
|
||
<section class="card">
|
||
<h2>관련 피드백 <span class="faint small">(${t.feedback.length})</span></h2>
|
||
${t.feedback.length ? `<ul class="mini">${t.feedback.map((f) => `<li><a href="#feedback/${f.id}">#${f.id}</a> ${badge(f.status, FEEDBACK_STATUS_LABEL)} <span class="faint small">${esc(fmtDate(f.created_at))} · ${esc(f.category || "")}</span><div class="clamp2 small">${esc(f.message)}</div></li>`).join("")}</ul>` : `<p class="faint">없음</p>`}
|
||
</section>
|
||
<section class="card">
|
||
<h2>수정 기록</h2>
|
||
${t.history.length ? `<ul class="mini">${t.history.map((h) => `<li><span class="small">${esc(fmtDate(h.at))} · ${esc(h.actor || "")} · ${esc(ACTION_LABEL[h.action] || h.action)}</span>
|
||
<div class="row nowrap">${h.before ? jsonBlock(h.before, "이전") : ""}${h.after ? jsonBlock(h.after, "이후") : ""}</div></li>`).join("")}</ul>` : `<p class="faint">없음</p>`}
|
||
</section>`}
|
||
</aside>
|
||
</div>`;
|
||
|
||
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) => `<tr data-id="${esc(s.id ?? "")}" data-section="${esc(s.section_id ?? "")}">
|
||
<td><select class="input" data-f="book_id" aria-label="교재">${bookOptions(s.book_id)}</select></td>
|
||
<td><input class="input num-in" data-f="page" type="number" min="0" value="${esc(s.page ?? "")}" aria-label="쪽"></td>
|
||
<td><input class="input num-in" data-f="pdf_page" type="number" min="0" value="${esc(s.pdf_page ?? "")}" aria-label="PDF 쪽"></td>
|
||
<td class="src-text"><input class="input" data-f="path" value="${esc(s.path ?? "")}" placeholder="목차 위치" aria-label="목차 위치">
|
||
<textarea class="input short" data-f="snippet" rows="2" placeholder="본문 문장" aria-label="본문 문장">${esc(s.snippet ?? "")}</textarea></td>
|
||
<td class="src-act"><button type="button" class="btn small" data-act="use-def">정의로 사용</button>
|
||
<button type="button" class="btn small danger" data-act="del-src" aria-label="이 출처 지우기">삭제</button></td></tr>`;
|
||
srcBody.innerHTML = (t.sources || []).map(srcRow).join("") || "";
|
||
const srcEmpty = () => { if (!srcBody.children.length) srcBody.innerHTML = `<tr class="empty-row"><td colspan="5" class="faint">출처가 없습니다.</td></tr>`; };
|
||
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 = `<p class="faint">연결된 관계가 없습니다.</p>`; return; }
|
||
box.innerHTML = `<ul class="rels">${t.relations.map((r) => {
|
||
const arrow = !r.directed ? "↔" : r.outgoing ? "→" : "←";
|
||
return `<li data-rid="${esc(r.relation_id)}"${r.hidden ? ' class="dim"' : ""}>
|
||
<div class="row rel-line"><span class="arrow" title="${r.directed ? (r.outgoing ? "이 용어에서 나가는 관계" : "이 용어로 들어오는 관계") : "방향 없음"}">${arrow}</span>
|
||
${termLink(r.other_id, r.other?.name)} ${r.other?.hidden ? '<span class="badge">숨김</span>' : ""}
|
||
<span class="faint small grow">${esc(METHOD_LABEL[r.method] || r.method || "")}${evidenceText(r.evidence) ? " · " + esc(evidenceText(r.evidence)) : ""}</span></div>
|
||
<div class="rel-edit">
|
||
<select class="input" data-f="type" aria-label="관계 유형">${opts(REL_TYPE_LABEL, r.type)}</select>
|
||
<input class="input" data-f="label" value="${esc(r.label || "")}" placeholder="설명" aria-label="관계 설명">
|
||
<select class="input" data-f="review_status" aria-label="검수 상태">${REL_STATUSES.map((k) => opt(k, STATUS_LABEL[k], r.review_status)).join("")}</select>
|
||
<button type="button" class="btn small" data-act="save">저장</button>
|
||
<button type="button" class="btn small danger" data-act="del" aria-label="관계 삭제">삭제</button>
|
||
</div></li>`;
|
||
}).join("")}</ul>`;
|
||
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 = `
|
||
<div class="page-head"><h1>관계</h1></div>
|
||
<details class="card addbox" id="ra-box"><summary>관계 추가</summary>
|
||
<form id="ra-form" class="ra-grid">
|
||
<div class="field"><span class="lbl">시작 용어</span><div id="ra-src"></div></div>
|
||
<div class="field"><span class="lbl">도착 용어</span><div id="ra-tgt"></div></div>
|
||
<label class="field"><span>유형</span><select class="input" name="type">${opts(REL_TYPE_LABEL, "related_to")}</select></label>
|
||
<label class="field"><span>설명(라벨)</span><input class="input" name="label" maxlength="200"></label>
|
||
<label class="field ra-reason"><span>근거</span><input class="input" name="reason" maxlength="2000"></label>
|
||
<div class="field ra-btn"><button class="btn primary" type="submit">추가</button></div>
|
||
</form>
|
||
</details>
|
||
<div class="filters" role="search">
|
||
<label class="grow"><span class="sr-only">검색</span><input class="input" id="rf-q" type="search" placeholder="용어 이름·설명" value="${esc(RF.q)}"></label>
|
||
<label><span class="sr-only">검수 상태</span><select class="input" id="rf-status">${opt("", "모든 상태", RF.status)}${REL_STATUSES.map((k) => opt(k, STATUS_LABEL[k], RF.status)).join("")}</select></label>
|
||
<label><span class="sr-only">유형</span><select class="input" id="rf-type">${opt("", "모든 유형", RF.type)}${opts(REL_TYPE_LABEL, RF.type)}</select></label>
|
||
<label><span class="sr-only">생성 방법</span><select class="input" id="rf-method">${opt("", "모든 방법", RF.method)}${opts(METHOD_LABEL, RF.method)}</select></label>
|
||
<label><span class="sr-only">은하</span><select class="input" id="rf-galaxy">${galaxyOptions(RF.galaxy)}</select></label>
|
||
</div>
|
||
<div class="bulkbar" id="rf-bulk" hidden>
|
||
<span id="rf-selcount"></span>
|
||
<button type="button" class="btn small" data-bulk='{"review_status":"confirmed"}'>확정</button>
|
||
<button type="button" class="btn small" data-bulk='{"review_status":"review"}'>검토 중</button>
|
||
<button type="button" class="btn small" data-bulk='{"review_status":"rejected"}'>반려</button>
|
||
<button type="button" class="btn small" data-bulk='{"hidden":true}'>숨기기</button>
|
||
<button type="button" class="btn small" data-bulk='{"hidden":false}'>다시 보이기</button>
|
||
<button type="button" class="btn small ghost" id="rf-clear">선택 해제</button>
|
||
</div>
|
||
<div id="rf-list">${loading()}</div>`;
|
||
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 ? `<div class="table-wrap"><table class="data rel-table">
|
||
<thead><tr><th scope="col" class="chk"><input type="checkbox" id="rf-all" aria-label="이 페이지 모두 선택"></th>
|
||
<th scope="col">연결</th><th scope="col">유형</th><th scope="col">설명</th><th scope="col">방법·근거</th><th scope="col">상태</th><th scope="col"><span class="sr-only">동작</span></th></tr></thead>
|
||
<tbody>${d.items.map((r) => `<tr data-rid="${esc(r.relation_id)}"${r.hidden ? ' class="dim"' : ""}>
|
||
<td class="chk"><input type="checkbox" data-sel="${esc(r.relation_id)}" aria-label="${esc(r.source_name)} ${r.directed ? "→" : "↔"} ${esc(r.target_name)} 선택"${RF.selected.has(r.relation_id) ? " checked" : ""}></td>
|
||
<td class="rel-pair">${termLink(r.source, r.source_name)} <span class="arrow">${r.directed ? "→" : "↔"}</span> ${termLink(r.target, r.target_name)}
|
||
${r.hidden ? ' <span class="badge">숨김</span>' : ""}${r.reason ? `<div class="faint small clamp2" title="${esc(r.reason)}">${esc(r.reason)}</div>` : ""}</td>
|
||
<td><select class="input compact" data-f="type" aria-label="유형">${opts(REL_TYPE_LABEL, r.type)}</select></td>
|
||
<td><input class="input compact" data-f="label" value="${esc(r.label || "")}" aria-label="설명"></td>
|
||
<td class="small">${esc(METHOD_LABEL[r.method] || r.method || "")}${evidenceText(r.evidence) ? `<br><span class="faint">${esc(evidenceText(r.evidence))}</span>` : ""}</td>
|
||
<td><select class="input compact" data-f="review_status" aria-label="검수 상태">${REL_STATUSES.map((k) => opt(k, STATUS_LABEL[k], r.review_status)).join("")}</select></td>
|
||
<td class="nowrap"><button type="button" class="btn small" data-act="save">저장</button> <button type="button" class="btn small danger" data-act="del">삭제</button></td></tr>`).join("")}</tbody>
|
||
</table></div>${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) => `<tr data-toc="${esc(s.toc_id)}">
|
||
<td class="nowrap small">${esc(bookShort(s.book_id))}<br><span class="faint">${esc(s.number || "")} · p.${esc(s.page ?? "")}</span></td>
|
||
<td><input class="input compact" data-f="title" value="${esc(s.title || "")}" aria-label="단원 제목"></td>
|
||
<td><input class="input compact" data-f="easy_sub" value="${esc(s.easy_sub || "")}" placeholder="쉬운 보조 설명" aria-label="쉬운 보조 설명"></td>
|
||
<td><select class="input compact" data-f="galaxy_id" aria-label="소속 은하">${galaxyOptions(s.galaxy_id, "(없음)")}</select></td>
|
||
<td class="nowrap">${s.edited ? '<span class="edited" title="사람이 고친 단원">✎</span> ' : ""}<button type="button" class="btn small" data-act="save-toc">저장</button></td></tr>`;
|
||
const secTable = (list) => list?.length ? `<div class="table-wrap"><table class="data"><thead><tr><th scope="col">교재</th><th scope="col">단원 제목</th><th scope="col">쉬운 보조 설명</th><th scope="col">은하</th><th scope="col"><span class="sr-only">동작</span></th></tr></thead>
|
||
<tbody>${list.map(secRow).join("")}</tbody></table></div>` : `<p class="faint">주 교재 단원이 없습니다.</p>`;
|
||
view.innerHTML = `<div id="st-root">
|
||
<div class="page-head"><h1>은하·단원</h1><p class="faint">은하는 큰 단원(장), 단원은 주 교재의 절입니다. 고친 항목은 파이프라인 결과를 다시 가져와도 유지됩니다.</p></div>
|
||
${d.galaxies.map((g) => `<section class="card galaxy" data-gid="${esc(g.galaxy_id)}">
|
||
<form class="g-form" data-gid="${esc(g.galaxy_id)}">
|
||
<span class="g-no">${esc(g.chapter_no ?? "")}</span>
|
||
<label class="field"><span>은하 이름</span><input class="input" name="name" value="${esc(g.name || "")}"></label>
|
||
<label class="field grow"><span>쉬운 보조 설명</span><input class="input" name="sub" value="${esc(g.sub || "")}"></label>
|
||
<label class="field"><span>색</span><input class="color" type="color" name="color" value="${esc(/^#[0-9a-f]{6}$/i.test(g.color || "") ? g.color : "#888888")}"></label>
|
||
<div class="field"><button class="btn small primary" type="submit">은하 저장</button>${g.edited ? ' <span class="edited" title="사람이 고친 은하">✎</span>' : ""}</div>
|
||
</form>
|
||
<details${(secsBy[g.galaxy_id] || []).length <= 8 ? " open" : ""}><summary>단원 ${(secsBy[g.galaxy_id] || []).length}개</summary>${secTable(secsBy[g.galaxy_id])}</details>
|
||
</section>`).join("")}
|
||
${secsBy[""]?.length ? `<section class="card"><h2>은하가 없는 단원</h2>${secTable(secsBy[""])}</section>` : ""}</div>`;
|
||
$$(".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 = `
|
||
<div class="page-head"><h1>설정</h1></div>
|
||
<section class="card"><h2>교수자 계정</h2>
|
||
<p class="faint small">관리 화면은 이 계정으로만 들어올 수 있습니다. 로그인은 <a href="/login">/login</a> 에서 합니다. 바꾸려면 현재 비밀번호를 입력하세요.</p>
|
||
<form id="acc-form" class="settings">
|
||
<label class="field"><span>아이디</span><input class="input" name="username" value="${esc(me.username || "")}" required minlength="3" maxlength="30" autocomplete="username"></label>
|
||
<label class="field"><span>이름 (수정 기록에 남는 이름)</span><input class="input" name="name" value="${esc(me.name || "")}" maxlength="40"></label>
|
||
<label class="field"><span>새 비밀번호 (바꿀 때만, 8자 이상)</span><input class="input" type="password" name="new_password" minlength="8" maxlength="100" autocomplete="new-password"></label>
|
||
<label class="field"><span>새 비밀번호 확인</span><input class="input" type="password" name="new_password2" autocomplete="new-password"></label>
|
||
<label class="field"><span>현재 비밀번호 (필수)</span><input class="input" type="password" name="current_password" required autocomplete="current-password"></label>
|
||
<p class="faint small">최근 로그인: ${esc(fmtDate(me.last_login) || "-")}</p>
|
||
<button type="submit" class="btn primary">계정 저장</button>
|
||
</form>
|
||
</section>
|
||
<form id="set-form" class="settings">
|
||
<section class="card">
|
||
<fieldset class="radios"><legend>학생 화면에 보여줄 검수 상태</legend>
|
||
<p class="faint small">학생 화면에 보여줄 검수 상태입니다. 검수가 진행되면 ‘확정’만 남기세요.</p>
|
||
${TERM_STATUSES.map((k) => `<label><input type="checkbox" name="public_statuses" value="${k}"${pub.has(k) ? " checked" : ""}> ${badge(k)}</label>`).join("")}
|
||
</fieldset>
|
||
</section>
|
||
<section class="card">
|
||
<label class="check"><input type="checkbox" name="show_status_badges"${s.show_status_badges ? " checked" : ""}> 학생 화면에 ‘검수 전’ 등 검수 상태 표시</label>
|
||
</section>
|
||
<div class="row"><button type="submit" class="btn primary">설정 저장</button></div>
|
||
</form>`;
|
||
$("#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 = `
|
||
<div class="page-head"><h1>기록</h1></div>
|
||
<div class="filters">
|
||
<label><span class="sr-only">대상</span><select class="input" id="af-entity">${opt("", "모든 대상", AF.entity)}${opts(ENTITY_LABEL, AF.entity)}</select></label>
|
||
</div>
|
||
<div id="af-list">${loading()}</div>`;
|
||
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 `<span class="faint">—</span>`;
|
||
if (a.entity === "term" && a.action !== "bulk") return termLink(a.entity_id);
|
||
if (a.entity === "feedback") return `<a href="#feedback/${esc(a.entity_id)}">#${esc(a.entity_id)}</a>`;
|
||
return `<span class="small break">${esc(a.entity_id.length > 60 ? a.entity_id.slice(0, 60) + "…" : a.entity_id)}</span>`;
|
||
};
|
||
box.innerHTML = d.items.length ? `<div class="table-wrap"><table class="data">
|
||
<thead><tr><th scope="col">시각</th><th scope="col">검수자</th><th scope="col">동작</th><th scope="col">대상</th><th scope="col">ID</th><th scope="col">이전</th><th scope="col">이후</th></tr></thead>
|
||
<tbody>${d.items.map((a) => `<tr>
|
||
<td class="nowrap small">${esc(fmtDate(a.at))}</td><td>${esc(a.actor || "")}</td>
|
||
<td>${esc(ACTION_LABEL[a.action] || a.action)}</td><td>${esc(ENTITY_LABEL[a.entity] || a.entity || "")}</td>
|
||
<td>${entLink(a)}</td><td>${jsonBlock(a.before, "보기")}</td><td>${jsonBlock(a.after, "보기")}</td></tr>`).join("")}</tbody>
|
||
</table></div>${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 = "반 선택") => `<option value="">${blank}</option>` + classes.map((k) => opt(k.class_id, k.name, cur)).join("");
|
||
const rate = (a, b) => (b ? `${Math.round((a / b) * 100)}%` : "-");
|
||
const SUBS = [["list", `회원 목록 <span class="count">${m.items.length}</span>`], ["codes", "가입 코드"], ["classes", "반"], ["stats", "학습 현황"]];
|
||
const cur = sub === "u" ? "list" : sub;
|
||
view.innerHTML = `
|
||
<div class="page-head"><h1>회원 관리</h1><p class="muted">학생은 수업에서 알려 준 <b>가입 코드</b>를 넣으면 바로 회원이 되어 사례와 문제를 볼 수 있습니다. 코드마다 반이 정해져 있어 그 반으로 들어갑니다.</p></div>
|
||
<nav class="subtabs">${SUBS.map(([k, l]) => `<a href="#members/${k}"${k === cur ? ' aria-current="page"' : ""}>${l}</a>`).join("")}</nav>
|
||
<div id="mem-body"></div>`;
|
||
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) => `<tr data-uid="${u.user_id}">
|
||
<td><a href="#members/u/${u.user_id}">${esc(u.username)}</a></td><td>${esc(u.name)}</td><td>${esc(u.student_no || "")}</td>
|
||
${pendingMode ? `<td class="small">${esc(u.note || "")}</td><td class="small"><code>${esc(u.signup_code || "")}</code></td><td class="small nowrap">${esc(fmtDate(u.created_at))}</td>`
|
||
: `<td>${badge(u.status, MEMBER_STATUS).replace(`badge ${u.status}`, `badge ${MEMBER_BADGE[u.status]}`)}</td>`}
|
||
<td><select class="input" data-class>${classOpts(u.class_id)}</select></td>
|
||
${pendingMode ? "" : `<td class="small">${u.attempts} · ${rate(u.correct, u.attempts)}</td><td class="small nowrap">${esc(fmtDate(u.last_login))}</td>`}
|
||
<td class="nowrap">${pendingMode ? `<button class="btn small primary" data-approve>승인</button> <button class="btn small danger" data-reject>거절</button>`
|
||
: `<button class="btn small" data-move>반 변경</button> ${u.status === "approved" ? `<button class="btn small" data-disable>사용 중지</button>` : `<button class="btn small" data-enable>다시 사용</button>`}`}</td></tr>`;
|
||
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 ? "" : `<p class="small warn-text">반이 없습니다. <a href="#members/classes">반</a>을 먼저 만든 뒤 가입 코드를 만들어 주세요.</p>`;
|
||
|
||
if (cur === "list") {
|
||
body.innerHTML = `<section class="card">
|
||
<div class="row filters-inline"><input class="input grow" id="mf-q" placeholder="아이디·이름·학번 검색">
|
||
<select class="input" id="mf-status" style="width:auto"><option value="">모든 상태</option>${Object.entries(MEMBER_STATUS).map(([k, l]) => opt(k, l, "")).join("")}</select>
|
||
<select class="input" id="mf-class" style="width:auto">${classOpts("", "모든 반")}</select></div>
|
||
<div id="mf-list"></div></section><section class="card" id="mem-detail" hidden></section>`;
|
||
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 ? `<p class="small muted">${items.length}명</p><div class="table-wrap"><table class="data"><thead><tr><th>아이디</th><th>이름</th><th>학번</th><th>상태</th><th>반</th><th>푼 문제·정답률</th><th>최근 로그인</th><th></th></tr></thead>
|
||
<tbody>${items.map((u) => memberRow(u, false)).join("")}</tbody></table></div>` : 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 = `<section class="card"><h2>새 가입 코드</h2>
|
||
${noClassWarn}<p class="small muted">수업 시간에 학생들에게 알려 줄 코드입니다. 코드를 넣고 가입하면 바로 회원이 되어 지정한 반에 들어갑니다. 코드를 비워 두면 자동으로 만듭니다(예: K7P-2QX).</p>
|
||
<form class="code-form" id="code-new">
|
||
<label class="field"><span>코드 (선택)</span><input class="input" name="code" placeholder="자동 생성" maxlength="40"></label>
|
||
<label class="field"><span>반 (필수)</span><select class="input" name="class_id" required>${classOpts("", "반 선택")}</select></label>
|
||
<label class="field"><span>사용 횟수 제한 (선택)</span><input class="input" name="max_uses" type="number" min="1" placeholder="예: 40"></label>
|
||
<label class="field"><span>만료일 (선택)</span><input class="input" name="expires_at" type="date"></label>
|
||
<label class="field"><span>메모 (선택)</span><input class="input" name="note" maxlength="200" placeholder="예: 3월 첫 수업"></label>
|
||
<button class="btn primary" type="submit">코드 만들기</button>
|
||
</form></section>
|
||
<section class="card"><h2>가입 코드 <span class="faint">${d.items.length}</span></h2>
|
||
${d.items.length ? `<div class="table-wrap"><table class="data"><thead><tr><th>코드</th><th>반</th><th>사용</th><th>만료일</th><th>메모</th><th>상태</th><th></th></tr></thead><tbody>
|
||
${d.items.map((k) => `<tr data-code="${k.code_id}"><td><code class="big-code">${esc(k.code)}</code> <button class="btn small ghost" data-copy="${esc(k.code)}">복사</button></td>
|
||
<td><select class="input" data-f="class_id">${classOpts(k.class_id, "반 선택")}</select></td>
|
||
<td class="small">${k.uses}${k.max_uses ? ` / ${k.max_uses}` : ""}</td>
|
||
<td><input class="input" type="date" data-f="expires_at" value="${esc(k.expires_at || "")}"></td>
|
||
<td><input class="input" data-f="note" value="${esc(k.note || "")}"></td>
|
||
<td><label class="check"><input type="checkbox" data-f="active"${k.active ? " checked" : ""}> 사용</label></td>
|
||
<td class="nowrap"><button class="btn small" data-save>저장</button> <button class="btn small danger" data-del>삭제</button></td></tr>`).join("")}
|
||
</tbody></table></div>` : empty("아직 가입 코드가 없습니다. 위에서 만들어 학생들에게 알려 주세요.")}</section>`;
|
||
$("#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 = `<section class="card">
|
||
<form class="row" id="class-new"><input class="input grow" name="name" placeholder="새 반 이름 (예: 2026-1 A반)" required maxlength="40"><input class="input grow" name="note" placeholder="메모 (선택)" maxlength="200"><button class="btn primary" type="submit">반 만들기</button></form>
|
||
${classes.length ? `<div class="table-wrap" style="margin-top:10px"><table class="data"><thead><tr><th>반</th><th>메모</th><th>회원</th><th>푼 문제</th><th>정답률</th><th></th></tr></thead><tbody>
|
||
${classes.map((k) => `<tr data-cid="${k.class_id}"><td><input class="input" data-cname value="${esc(k.name)}" maxlength="40"></td><td><input class="input" data-cnote value="${esc(k.note || "")}"></td>
|
||
<td><a href="#members/list" data-filter-class="${k.class_id}">${k.members}</a></td><td>${k.attempts}</td><td>${rate(k.correct, k.attempts)}</td>
|
||
<td class="nowrap"><button class="btn small" data-csave>저장</button> <a class="btn small" href="#members/stats/${k.class_id}">학습 현황</a> <button class="btn small danger" data-cdel>삭제</button></td></tr>`).join("")}
|
||
</tbody></table></div>` : empty("반이 없습니다.")}</section>`;
|
||
$("#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 = `<section class="card"><div class="row"><label class="small muted">반</label><select class="input" id="st-class" style="width:auto">${classOpts(cid, "전체")}</select></div><div id="st-body">${loading()}</div></section>`;
|
||
$("#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 = `<div class="split-2" style="margin-top:12px"><div><h3 class="small muted">단원별 정답률</h3>${d.by_galaxy.length ? d.by_galaxy.map((g) => {
|
||
const p = pct(g.correct, g.attempts);
|
||
return `<div class="row small"><span class="grow">${esc(g.name)}</span><span>${g.correct}/${g.attempts} · ${p}%</span></div><div class="pbar"><i style="width:${p}%"></i></div>`;
|
||
}).join("") : empty("아직 푼 문제가 없습니다.")}</div>
|
||
<div><h3 class="small muted">많이 틀린 용어</h3>${d.wrong_terms.length ? `<ul class="plain">${d.wrong_terms.map((t) => `<li>${termLink(t.term_id, t.name)} <span class="faint">${t.wrong}/${t.attempts} 오답</span></li>`).join("")}</ul>` : empty("틀린 용어가 없습니다.")}</div></div>`;
|
||
}
|
||
};
|
||
|
||
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 = `<div class="row"><h2 class="grow">${esc(u.name)} <span class="faint small">${esc(u.username)}${u.student_no ? " · " + esc(u.student_no) : ""}</span></h2>
|
||
${badge(u.status, MEMBER_STATUS).replace(`badge ${u.status}`, `badge ${MEMBER_BADGE[u.status]}`)} <a class="btn small" href="#members/list">닫기</a></div>
|
||
<p class="small muted">반: ${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) || "-")}</p>
|
||
${u.note ? `<p class="small">남긴 말: ${esc(u.note)}</p>` : ""}
|
||
<p><b>${total}</b>문제 중 <b>${right}</b>개 정답 (${total ? Math.round((right / total) * 100) : 0}%)</p>
|
||
<div class="split-2"><div><h3 class="small muted">단원별</h3>${u.by_galaxy.length ? u.by_galaxy.map((g) => {
|
||
const p = pct(g.correct, g.attempts);
|
||
return `<div class="row small"><span class="grow">${esc(g.name)}</span><span>${g.correct}/${g.attempts}</span></div><div class="pbar"><i style="width:${p}%"></i></div>`;
|
||
}).join("") : empty("푼 문제가 없습니다.")}
|
||
<h3 class="small muted" style="margin-top:12px">최근 학습일</h3>${u.days.length ? `<ul class="plain small">${u.days.map((d) => `<li>${esc(d.day)} · ${d.attempts}문제 · 정답 ${d.correct}</li>`).join("")}</ul>` : empty("기록 없음")}</div>
|
||
<div><h3 class="small muted">자주 틀린 용어</h3>${u.wrong_terms.length ? `<ul class="plain">${u.wrong_terms.map((t) => `<li>${termLink(t.term_id, t.name)} <span class="faint">${t.wrong}회</span></li>`).join("")}</ul>` : empty("없음")}
|
||
<h3 class="small muted" style="margin-top:12px">최근 풀이</h3>${u.recent.length ? `<ul class="plain small">${u.recent.map((r) => `<li>${r.correct ? "⭕" : "❌"} ${esc(r.term_name || "-")} <span class="faint">${esc(fmtDate(r.created_at))}</span></li>`).join("")}</ul>` : empty("없음")}</div></div>
|
||
<div class="row" style="margin-top:12px">
|
||
<button class="btn small" data-reset>비밀번호 재설정</button>
|
||
${u.status === "approved" ? `<button class="btn small" data-disable>사용 중지</button>` : ""}
|
||
<button class="btn small danger" data-del>회원 삭제</button></div>`;
|
||
$("[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) => `<button type="button" class="btn small ghost" data-copy="${esc(c)}">복사</button>`;
|
||
box.innerHTML = `<div class="row"><h2 class="grow">가입 코드</h2><a class="btn small" href="#codes">코드 관리</a></div>
|
||
${classes.items.length ? `<form class="row" id="qc-form">
|
||
<select class="input" name="class_id" required style="width:auto">${classes.items.map((k) => opt(k.class_id, k.name, "")).join("")}</select>
|
||
<input class="input" name="max_uses" type="number" min="1" placeholder="사용 횟수 (선택)" style="width:150px">
|
||
<input class="input" name="expires_at" type="date" title="만료일 (선택)" style="width:auto">
|
||
<button class="btn primary" type="submit">코드 만들기</button></form>
|
||
<div id="qc-new"></div>`
|
||
: `<form class="row" id="qc-class"><span class="small muted">먼저 반을 만들어 주세요.</span><input class="input grow" name="name" placeholder="반 이름 (예: 2026-1 A반)" required maxlength="40"><button class="btn primary" type="submit">반 만들기</button></form>`}
|
||
${active.length ? `<div class="qc-list">${active.map((k) => `<div class="qc-item"><code class="big-code">${esc(k.code)}</code><span class="small muted">${esc(k.class_name || "")} · ${k.uses}${k.max_uses ? "/" + k.max_uses : ""}명${k.expires_at ? " · ~" + esc(k.expires_at) : ""}</span>${copyBtn(k.code)}</div>`).join("")}</div>`
|
||
: `<p class="small muted" style="margin-top:8px">사용 중인 가입 코드가 없습니다.</p>`}`;
|
||
$("#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 = `<div class="qc-made">새 코드 <code class="big-code">${esc(r.code)}</code> ${copyBtn(r.code)} <span class="small muted">학생들에게 알려 주세요. /signup 에서 입력합니다.</span></div>`;
|
||
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 = `
|
||
<div class="page-head"><h1>문제</h1><p class="muted">쉬운 설명이 있는 용어는 자동 객관식(뜻→용어, 용어→뜻)이 만들어집니다. 여기서는 직접 출제한 문제를 관리합니다.
|
||
‘검토 중’·‘확정’ 문제만 회원에게 나갑니다.</p></div>
|
||
${termId ? `<p>용어: <b>${esc(termName || termId)}</b> · ${termLink(termId, "용어 편집")} · <a href="#questions">전체 문제</a></p>` : ""}
|
||
<div class="split-2">
|
||
<section class="card"><h2 id="q-form-title">새 문제</h2><form id="q-form">
|
||
<label class="field"><span>관련 용어 (선택)</span><input class="input" name="term_q" placeholder="용어 이름으로 찾기" value="${esc(termName)}" autocomplete="off"><input type="hidden" name="term_id" value="${esc(termId || "")}"><div class="q-ac" id="q-ac" hidden></div></label>
|
||
<label class="field"><span>유형</span><select class="input" name="qtype"><option value="mcq">객관식</option><option value="ox">OX</option></select></label>
|
||
<label class="field"><span>문제</span><textarea class="input" name="prompt" required maxlength="2000"></textarea></label>
|
||
<label class="field" data-mcq><span>보기 (한 줄에 하나, 2~6개)</span><textarea class="input" name="choices"></textarea></label>
|
||
<label class="field"><span>정답</span><select class="input" name="answer"></select></label>
|
||
<label class="field"><span>해설 (선택)</span><textarea class="input" name="explanation" maxlength="2000"></textarea></label>
|
||
<label class="field"><span>검수 상태</span><select class="input" name="review_status">${opts(STATUS_LABEL, "review").replace(/<option value="rejected".*?<\/option>/, "")}</select></label>
|
||
<label class="check"><input type="checkbox" name="hidden"> 숨기기</label>
|
||
<div class="row" style="margin-top:10px"><button class="btn primary" type="submit">저장</button><button class="btn" type="button" id="q-reset">새로 쓰기</button></div>
|
||
</form></section>
|
||
<section class="card"><h2>자동 문제 미리보기</h2><div id="q-preview">${termId ? loading() : empty("관련 용어를 고르면 그 용어로 만들어지는 자동 문제를 보여 줍니다.")}</div></section>
|
||
</div>
|
||
<section class="card"><h2>직접 출제한 문제 <span class="faint">${d.items.length}</span></h2>
|
||
${d.items.length ? `<div class="table-wrap"><table class="data"><thead><tr><th>#</th><th>용어</th><th>유형</th><th>문제</th><th>정답</th><th>상태</th><th>풀이·정답률</th><th></th></tr></thead><tbody>
|
||
${d.items.map((q) => `<tr data-qid="${q.question_id}"><td>${q.question_id}</td><td>${q.term_id ? termLink(q.term_id, q.term_name) : `<span class="faint">-</span>`}</td>
|
||
<td>${q.qtype === "ox" ? "OX" : "객관식"}</td><td class="small">${esc(q.prompt)}</td>
|
||
<td class="small">${esc(q.qtype === "ox" ? ["O", "X"][q.answer] : (q.choices || [])[q.answer] || "")}</td>
|
||
<td>${badge(q.review_status)}${q.hidden ? ` <span class="badge">숨김</span>` : ""}</td><td class="small">${q.attempts} · ${q.attempts ? pct(q.correct, q.attempts) + "%" : "-"}</td>
|
||
<td class="nowrap"><button class="btn small" data-edit>수정</button> <button class="btn small danger" data-del>삭제</button></td></tr>`).join("")}
|
||
</tbody></table></div>` : empty("직접 출제한 문제가 없습니다.")}
|
||
</section>`;
|
||
|
||
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("") || `<option value="">보기를 먼저 입력하세요</option>`;
|
||
};
|
||
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) => `<div class="q-prev"><p><b>${esc(q.prompt)}</b></p>${q.quote ? `<p class="small muted">「${esc(q.quote)}」</p>` : ""}
|
||
<ol class="small">${q.choices.map((c, i) => `<li${i === q.answer ? ' class="ok-text"' : ""}>${esc(c)}${i === q.answer ? " ✓" : ""}</li>`).join("")}</ol></div>`).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) => `<button type="button" data-tid="${esc(t.term_id)}">${esc(t.name)} <span class="faint">${esc(S.galaxyById[t.galaxy_id]?.name || "")}</span></button>`).join("") || `<div class="faint small">없음</div>`;
|
||
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);
|
||
}
|
||
})();
|