${escapeHtml(log.preview || "")}
const csrfToken = document.body.dataset.csrfToken || ""; const hideInactiveUsersStorageKey = "consoleSuperadmin.hideInactiveUsers"; const consoleActionStorageKey = "consoleSuperadmin.selectedAction"; let latestUsersPayload = {users: []}; let selectedUserIds = new Set(); function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function fmtNumber(value, digits = 0) { const number = Number(value || 0); return Number.isFinite(number) ? number.toLocaleString("ko-KR", {maximumFractionDigits: digits}) : "-"; } function fmtKrw(value) { return `${fmtNumber(value, 0)} KRW`; } async function fetchJson(url, options = {}) { const response = await fetch(url, { cache: "no-store", credentials: "include", ...options, headers: { Accept: "application/json", ...(options.body ? {"Content-Type": "application/json"} : {}), ...(options.method && options.method !== "GET" ? {"X-CSRF-Token": csrfToken} : {}), ...(options.headers || {}), }, }); const data = await response.json().catch(() => ({})); if (!response.ok) { const detail = data.message || data.error || `HTTP ${response.status}`; const error = new Error(detail); error.status = response.status; error.payload = data; console.error("Console API request failed", {url, status: response.status, payload: data}); throw error; } return data; } function clearAuthStorage() { const authKeyPattern = /auth|oauth|token|session|user/i; for (const storage of [window.localStorage, window.sessionStorage]) { if (!storage) continue; const keys = []; for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index); if (key && authKeyPattern.test(key)) keys.push(key); } keys.forEach((key) => storage.removeItem(key)); } } function describeError(error) { const payload = error?.payload || {}; const parts = [ payload.message || error?.message || "요청 처리에 실패했습니다.", payload.error ? `error=${payload.error}` : "", payload.operation ? `operation=${payload.operation}` : "", payload.max_assignable_krw !== undefined ? `max=${fmtNumber(payload.max_assignable_krw, 0)} KRW` : "", payload.requested_budget_krw !== undefined ? `requested=${fmtNumber(payload.requested_budget_krw, 0)} KRW` : "", ].filter(Boolean); return parts.join("\n"); } function formPayload(form) { const payload = Object.fromEntries(new FormData(form).entries()); for (const key of ["auto_trade_allowed"]) { if (key in payload) payload[key] = payload[key] === "true"; } for (const key of ["investment_krw", "allocation_pct", "amount", "budget_limit_krw"]) { if (key in payload) payload[key] = Number(payload[key] || 0); } return payload; } function selectedConsoleAction() { return document.getElementById("consoleActionSelect")?.value || "create_user"; } function syncConsoleActionForms() { const action = selectedConsoleAction(); localStorage.setItem(consoleActionStorageKey, action); document.querySelectorAll("[data-console-action-form]").forEach((element) => { const formAction = element.dataset.consoleActionForm; const shouldShow = formAction === action || (element.id === "cashflowQaForm" && ["deposit", "withdrawal"].includes(action)); element.hidden = !shouldShow; if (element.id === "cashflowQaForm" && shouldShow) { element.elements.type.value = action; } }); document.getElementById("accounts")?.classList.toggle("is-action-muted", action !== "account_manage"); document.getElementById("cashflow")?.classList.toggle("is-action-muted", !["deposit", "withdrawal"].includes(action)); document.getElementById("allocations")?.classList.toggle("is-action-muted", action !== "strategy_allocation"); updateBulkUserSelectionUi(); if (action === "strategy_allocation") refreshAllocationCapacity(); } function rowsOrEmpty(rows, colSpan) { return rows.length ? rows.join("") : `
${escapeHtml(row.parameters_json || "{}")}${escapeHtml(log.preview || "")}
로그가 없습니다.
"; } async function refreshTrades(event) { event?.preventDefault(); const form = document.getElementById("tradeFilterForm"); const params = new URLSearchParams(new FormData(form)); const data = await fetchJson(`/api/console/superadmin/trades?${params.toString()}`); renderTrades(data); } async function refreshDailyPnl(event) { event?.preventDefault(); const form = document.getElementById("dailyPnlFilterForm"); const params = new URLSearchParams(new FormData(form)); const data = await fetchJson(`/api/superadmin/user-strategy-daily-pnl?${params.toString()}`); renderDailyPnl(data); } async function refreshAll() { const [status, users, accounts, cashflow, strategies, allocations, investorSummary, dailyPnl, control, trades, pnl, logs] = await Promise.all([ fetchJson("/api/console/superadmin/status"), fetchJson("/api/superadmin/users"), fetchJson("/api/console/superadmin/accounts"), fetchJson("/api/superadmin/user-deposits-withdrawals"), fetchJson("/api/console/superadmin/strategies"), fetchJson("/api/superadmin/user-strategy-settings"), fetchJson("/api/superadmin/investor-strategy-summary"), fetchJson("/api/superadmin/user-strategy-daily-pnl?strategy_code=live_strategy1_btc"), fetchJson("/api/console/superadmin/control-status"), fetchJson("/api/console/superadmin/trades?strategy=live_strategy1_btc&market=KRW-BTC&run_mode=real&limit=100"), fetchJson("/api/console/superadmin/pnl?strategy=live_strategy1_btc&market=KRW-BTC"), fetchJson("/api/console/superadmin/logs"), ]); renderStatus(status); renderUsers(users); renderAccounts(accounts); renderCashflow(cashflow); renderStrategies(strategies); renderAllocations(allocations); renderInvestorStrategySummary(investorSummary); renderDailyPnl(dailyPnl); renderControl(control); renderTrades(trades); renderPnl(pnl); renderLogs(logs); await refreshAllocationCapacity(); } function bindDryRunForm(formId, endpoint, refreshFn) { document.getElementById(formId)?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const button = form.querySelector("button[type='submit']"); button.disabled = true; try { const payload = formPayload(form); payload.action = form.dataset.consoleActionForm || selectedConsoleAction(); await fetchJson(endpoint, {method: "POST", body: JSON.stringify(payload)}); form.reset(); await refreshFn(); } catch (error) { window.alert(`저장 실패:\n${describeError(error)}`); } finally { button.disabled = false; } }); } async function refreshUsersOnly() { renderUsers(await fetchJson("/api/superadmin/users")); } async function submitBulkDisableUsers(reason) { const userIds = Array.from(selectedUserIds); if (!userIds.length) throw new Error("selected_users_required"); const result = await fetchJson("/api/superadmin/console-action", { method: "POST", body: JSON.stringify({action: "disable_users", user_ids: userIds, reason}), }); const disabledCount = Number(result.disabled_count || (result.disabled_users || []).length || 0); const skipped = result.skipped || []; selectedUserIds = new Set(); await refreshUsersOnly(); if (skipped.length) { showToast(`비활성화 ${disabledCount}명 완료, ${skipped.length}명 스킵됨`, "warning"); } else { showToast(`비활성화 ${disabledCount}명 완료`); } return result; } document.getElementById("usersBody")?.addEventListener("click", async (event) => { const enableButton = event.target.closest("[data-enable-user]"); if (!enableButton) return; enableButton.disabled = true; try { await fetchJson("/api/superadmin/users/enable", { method: "POST", body: JSON.stringify({user_id: enableButton.dataset.enableUser}), }); selectedUserIds.delete(enableButton.dataset.enableUser); showToast("사용자를 활성화했습니다."); await refreshUsersOnly(); } catch (error) { showToast(`사용자 활성화 실패: ${error.message}`, "warning"); } finally { enableButton.disabled = false; } }); document.getElementById("usersBody")?.addEventListener("change", (event) => { const checkbox = event.target.closest(".user-select-checkbox"); if (!checkbox) return; if (checkbox.checked) selectedUserIds.add(checkbox.value); else selectedUserIds.delete(checkbox.value); updateBulkUserSelectionUi(); }); document.getElementById("selectAllUsersCheckbox")?.addEventListener("change", (event) => { const visibleIds = visibleSelectableUserIds(); selectedUserIds = event.currentTarget.checked ? new Set(visibleIds) : new Set(); updateBulkUserSelectionUi(); }); document.getElementById("bulkDisableUsersButton")?.addEventListener("click", () => { if (selectedUserIds.size === 0) return; const dialog = document.getElementById("disableUserDialog"); const form = document.getElementById("disableUserForm"); form?.reset(); const countLabel = document.getElementById("disableUserSelectedCount"); if (countLabel) countLabel.textContent = `선택 ${selectedUserIds.size}명`; if (dialog?.showModal) { dialog.showModal(); form?.elements.reason?.focus(); return; } const reason = window.prompt(`선택한 유저 ${selectedUserIds.size}명을 비활성화하시겠습니까?\n비활성화 사유를 입력하세요.`); if (!reason) return; submitBulkDisableUsers(reason); }); document.querySelector("[data-close-disable-dialog]")?.addEventListener("click", () => { document.getElementById("disableUserDialog")?.close(); }); document.getElementById("disableUserForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const button = form.querySelector("button[type='submit']"); button.disabled = true; try { await submitBulkDisableUsers(formPayload(form).reason || ""); document.getElementById("disableUserDialog")?.close(); form.reset(); } catch (error) { showToast(`사용자 비활성화 실패: ${error.message}`, "warning"); } finally { button.disabled = false; } }); syncHideInactiveUsersCheckbox(); document.getElementById("hideInactiveUsersCheckbox")?.addEventListener("change", (event) => { localStorage.setItem(hideInactiveUsersStorageKey, event.currentTarget.checked ? "true" : "false"); renderUsers(latestUsersPayload); }); document.getElementById("allocationQaForm")?.addEventListener("input", () => { refreshAllocationCapacity(); }); document.getElementById("consoleActionSelect")?.addEventListener("change", syncConsoleActionForms); document.getElementById("refreshConsoleButton")?.addEventListener("click", refreshAll); document.getElementById("consoleLogoutLink")?.addEventListener("click", async (event) => { event.preventDefault(); clearAuthStorage(); let loginUrl = "/auth/google?next=/console"; try { const result = await fetchJson("/api/auth/logout", {method: "POST"}); loginUrl = result.login_url || loginUrl; } catch (error) { console.error("Console logout failed; clearing local auth state anyway.", error); } finally { clearAuthStorage(); window.location.replace(loginUrl); } }); document.getElementById("tradeFilterForm")?.addEventListener("submit", refreshTrades); document.getElementById("dailyPnlFilterForm")?.addEventListener("submit", refreshDailyPnl); document.querySelectorAll("[data-control-action]").forEach((button) => { button.addEventListener("click", async () => { button.disabled = true; try { const data = await fetchJson("/api/console/superadmin/control-status", { method: "POST", body: JSON.stringify({action: button.dataset.controlAction}), }); renderControl(data); } finally { button.disabled = false; } }); }); bindDryRunForm("userQaForm", "/api/superadmin/console-action", async () => renderUsers(await fetchJson("/api/superadmin/users"))); bindDryRunForm("accountQaForm", "/api/superadmin/console-action", async () => renderAccounts(await fetchJson("/api/console/superadmin/accounts"))); document.getElementById("cashflowQaForm")?.addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; const payload = formPayload(form); payload.action = payload.type === "withdrawal" ? "withdrawal" : "deposit"; const button = form.querySelector("button[type='submit']"); button.disabled = true; try { await fetchJson("/api/superadmin/console-action", {method: "POST", body: JSON.stringify(payload)}); form.reset(); renderCashflow(await fetchJson("/api/superadmin/user-deposits-withdrawals")); renderInvestorStrategySummary(await fetchJson("/api/superadmin/investor-strategy-summary")); await refreshUsersOnly(); await refreshAllocationCapacity(); } catch (error) { window.alert(`입출금 저장 실패:\n${describeError(error)}`); } finally { button.disabled = false; } }); bindDryRunForm("allocationQaForm", "/api/superadmin/console-action", async () => { renderAllocations(await fetchJson("/api/superadmin/user-strategy-settings")); renderDailyPnl(await fetchJson("/api/superadmin/user-strategy-daily-pnl?strategy_code=live_strategy1_btc")); await refreshAllocationCapacity(); }); const storedAction = localStorage.getItem(consoleActionStorageKey); if (storedAction && document.getElementById("consoleActionSelect")) { document.getElementById("consoleActionSelect").value = storedAction; } syncConsoleActionForms(); refreshAll().catch((error) => { document.getElementById("apiStatusBody").textContent = `콘솔 데이터를 불러오지 못했습니다.\n${describeError(error)}`; });