Files
moneyguide/imweb/imweb-members.html
T
ttipo 2f01bea6ef fix: 운영 백엔드 주소 오타 수정 (moneyguider → moneyguide.wandergo.net)
운영 백엔드 도메인은 moneyguide.wandergo.net 인데 관리자 화면 API_BASE 와 문서가
moneyguider.wandergo.net 을 가리켜 로그인 요청이 TLS 단계에서 거절되었다
(ERR_SSL_UNRECOGNIZED_NAME_ALERT). 아임웹 사이트 주소 moneyguider.co.kr 은 그대로 둔다.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 23:14:11 +09:00

1528 lines
80 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- ============================================================
[자본주의가이드] 회원 유입경로 관리자 화면 (대시보드)
설치 위치: 아임웹 관리자 전용 페이지 > 코드 위젯 (HTML)
- 페이지 접근 권한을 '관리자'로 제한해 둔다 (1차 보호)
- 데이터 API 는 백엔드 관리자 로그인으로 따로 보호된다 (2차 보호)
로컬 테스트: node imweb/serve-local.js → http://localhost:5173/imweb-members.html
API 주소: 아래 API_BASE — localhost 면 로컬 백엔드, 그 외에는 운영 백엔드
인증: 백엔드가 다른 사이트(wandergo.net)라 쿠키 대신 Authorization 헤더로 토큰을 보낸다.
토큰은 sessionStorage 에만 둔다 — 탭을 닫으면 사라지고 다시 로그인해야 한다.
============================================================ -->
<div id="mg-members">
<style>
#mg-members {
--mgm-bg: #f4f6f9;
--mgm-surface: #ffffff;
--mgm-border: #e3e7ec;
--mgm-text: #1b1f27;
--mgm-muted: #6b7380;
--mgm-side: #151a23;
--mgm-side-text: #aab2c0;
--mgm-side-active: #ffffff;
--mgm-accent: #2f6fed;
--mgm-accent-soft: #e8efff;
--mgm-warn: #b45309;
--mgm-warn-soft: #fff4e5;
--mgm-danger: #c62828;
--mgm-danger-soft: #fdecec;
--mgm-ok: #1b7f4b;
--mgm-ok-soft: #e7f6ee;
font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Pretendard", "Noto Sans KR", sans-serif;
font-size: 14px;
line-height: 1.5;
color: var(--mgm-text);
/* 놓인 영역의 폭을 모두 쓴다 — 아임웹에서는 위젯이 들어간 섹션 폭을 따른다 */
width: 100%;
box-sizing: border-box;
}
#mg-members *, #mg-members *::before, #mg-members *::after { box-sizing: border-box; }
#mg-members [hidden] { display: none !important; }
#mg-members h1, #mg-members h2, #mg-members h3 { margin: 0; font-weight: 700; }
#mg-members h1 { font-size: 20px; }
#mg-members h3 { font-size: 15px; }
#mg-members .mgm-muted { color: var(--mgm-muted); font-size: 13px; }
#mg-members .mgm-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
#mg-members .mgm-spacer { flex: 1 1 auto; }
/* 공통 컨트롤 */
#mg-members button {
font: inherit; font-size: 13px; cursor: pointer;
border: 1px solid var(--mgm-border); background: var(--mgm-surface); color: var(--mgm-text);
padding: 6px 12px; border-radius: 8px; white-space: nowrap;
}
#mg-members button:hover { border-color: #c5cad2; }
#mg-members button:disabled { opacity: .5; cursor: default; }
#mg-members button.mgm-primary { background: var(--mgm-accent); border-color: var(--mgm-accent); color: #fff; }
#mg-members button.mgm-link { border: 0; background: none; color: var(--mgm-accent); padding: 4px 6px; }
#mg-members button.mgm-chip.is-active { background: var(--mgm-accent-soft); border-color: var(--mgm-accent); color: var(--mgm-accent); }
#mg-members input, #mg-members select {
font: inherit; font-size: 13px; padding: 6px 8px; border: 1px solid var(--mgm-border);
border-radius: 8px; background: #fff; color: var(--mgm-text); min-width: 0; width: 100%;
}
#mg-members .mgm-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--mgm-muted); min-width: 0; }
#mg-members input, #mg-members select { height: 34px; }
/* 셀렉트 — 브라우저 기본 화살표 대신 드롭다운과 같은 화살표 */
#mg-members select {
appearance: none; -webkit-appearance: none; padding-right: 30px; cursor: pointer;
background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%236b7380' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E") no-repeat right 10px center;
}
#mg-members .mgm-badge {
display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 999px;
background: var(--mgm-bg); color: var(--mgm-muted); border: 1px solid var(--mgm-border);
}
#mg-members .mgm-badge.is-ok { background: var(--mgm-ok-soft); color: var(--mgm-ok); border-color: transparent; }
#mg-members .mgm-badge.is-warn { background: var(--mgm-warn-soft); color: var(--mgm-warn); border-color: transparent; }
#mg-members .mgm-badge.is-danger { background: var(--mgm-danger-soft); color: var(--mgm-danger); border-color: transparent; }
#mg-members .mgm-notice { padding: 10px 12px; border-radius: 8px; margin-bottom: 16px; font-size: 13px; }
#mg-members .mgm-notice.is-error { background: var(--mgm-danger-soft); color: var(--mgm-danger); }
#mg-members .mgm-notice.is-ok { background: var(--mgm-ok-soft); color: var(--mgm-ok); }
#mg-members .mgm-trace { font-size: 11px; opacity: .7; }
#mg-members .mgm-card {
background: var(--mgm-surface); border: 1px solid var(--mgm-border);
border-radius: 12px; padding: 18px;
}
#mg-members .mgm-card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; }
/* 로그인 */
#mg-members .mgm-login-wrap { background: var(--mgm-bg); border-radius: 14px; padding: 56px 16px; }
#mg-members .mgm-login { max-width: 360px; margin: 0 auto; }
#mg-members .mgm-login form { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
/* 셸: 왼쪽 메뉴 + 오른쪽 본문 */
#mg-members .mgm-shell {
--mgm-side-width: 224px;
display: grid; grid-template-columns: var(--mgm-side-width) minmax(0, 1fr);
min-height: 760px; border-radius: 14px;
/* overflow:hidden 은 스크롤 컨테이너를 만들어 사이드바 sticky 를 깨뜨린다 — clip 은 그렇지 않다 */
overflow: clip;
border: 1px solid var(--mgm-border);
/* 사이드바가 화면 높이로 고정돼 움직여도 왼쪽 열이 끊겨 보이지 않게 열 배경을 같은 색으로 칠한다 */
background: linear-gradient(to right, var(--mgm-side) var(--mgm-side-width), var(--mgm-bg) var(--mgm-side-width));
}
/* 본문을 내려도 메뉴·로그아웃이 보이도록 화면 높이에 고정한다.
아임웹 상단 메뉴가 화면 위에 고정돼 있으면 --mgm-sticky-top 에 그 높이를 넣는다 */
#mg-members .mgm-side {
background: var(--mgm-side); color: var(--mgm-side-text); display: flex; flex-direction: column; padding: 20px 14px; gap: 18px;
position: sticky; top: var(--mgm-sticky-top, 0px); align-self: start;
height: calc(100vh - var(--mgm-sticky-top, 0px)); overflow-y: auto;
}
#mg-members .mgm-brand { color: #fff; padding: 0 8px; }
#mg-members .mgm-brand strong { display: block; font-size: 15px; }
#mg-members .mgm-brand span { font-size: 12px; color: var(--mgm-side-text); }
#mg-members .mgm-nav { display: flex; flex-direction: column; gap: 2px; }
#mg-members .mgm-nav button {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: none; border: 0; color: var(--mgm-side-text); padding: 9px 10px; border-radius: 8px; font-size: 14px;
}
#mg-members .mgm-nav button:hover { background: rgba(255,255,255,.06); color: #fff; }
#mg-members .mgm-nav button.is-active { background: rgba(255,255,255,.12); color: var(--mgm-side-active); font-weight: 600; }
#mg-members .mgm-nav svg { width: 18px; height: 18px; flex: none; }
#mg-members .mgm-side-foot {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 0 8px 14px; border-bottom: 1px solid rgba(255,255,255,.08); font-size: 13px; white-space: nowrap;
}
#mg-members .mgm-side-foot span { overflow: hidden; text-overflow: ellipsis; }
#mg-members .mgm-side-foot button { flex: none; background: none; border-color: rgba(255,255,255,.18); color: var(--mgm-side-text); padding: 4px 10px; font-size: 12px; }
#mg-members .mgm-side-foot button:hover { color: #fff; border-color: rgba(255,255,255,.4); }
#mg-members .mgm-main { padding: 22px 24px 28px; min-width: 0; }
#mg-members .mgm-topbar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
#mg-members .mgm-topbar p { margin: 2px 0 0; }
#mg-members .mgm-sync-pill { font-size: 12px; color: var(--mgm-muted); background: var(--mgm-surface); border: 1px solid var(--mgm-border); border-radius: 999px; padding: 4px 10px; }
/* 개요 */
#mg-members .mgm-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
#mg-members .mgm-kpi { cursor: pointer; text-align: left; padding: 16px 18px; border-radius: 12px; }
#mg-members .mgm-kpi:hover { border-color: var(--mgm-accent); }
#mg-members .mgm-kpi .mgm-kpi-label { font-size: 12px; color: var(--mgm-muted); }
#mg-members .mgm-kpi .mgm-kpi-value { display: block; font-size: 26px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; }
#mg-members .mgm-kpi .mgm-kpi-sub { font-size: 12px; color: var(--mgm-muted); }
#mg-members .mgm-grid-2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr); gap: 16px; }
/* 막대 */
#mg-members .mgm-bars { display: flex; flex-direction: column; gap: 6px; }
#mg-members .mgm-bar {
display: grid; grid-template-columns: minmax(80px, 200px) 1fr 64px; gap: 10px; align-items: center;
border: 0; background: none; padding: 3px 4px; text-align: left; border-radius: 6px; width: 100%;
}
#mg-members .mgm-bar:hover:not(:disabled) { background: var(--mgm-bg); }
#mg-members .mgm-bar-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#mg-members .mgm-bar-track, #mg-members .mgm-bar-fill { display: block; }
#mg-members .mgm-bar-track { height: 10px; background: var(--mgm-bg); border-radius: 5px; overflow: hidden; }
#mg-members .mgm-bar-fill { height: 100%; background: var(--mgm-accent); border-radius: 5px; }
#mg-members .mgm-bar-count { text-align: right; font-variant-numeric: tabular-nums; }
/* 필터 */
#mg-members .mgm-filter-top { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
#mg-members .mgm-search { position: relative; flex: 1 1 280px; min-width: 220px; }
#mg-members .mgm-search svg { position: absolute; left: 11px; top: 50%; width: 16px; height: 16px; transform: translateY(-50%); color: var(--mgm-muted); pointer-events: none; }
#mg-members .mgm-search input { padding: 9px 12px 9px 34px; font-size: 14px; }
#mg-members .mgm-seg { display: inline-flex; border: 1px solid var(--mgm-border); border-radius: 8px; overflow: hidden; background: var(--mgm-surface); }
#mg-members .mgm-seg button { border: 0; border-radius: 0; padding: 8px 12px; background: none; }
#mg-members .mgm-seg button + button { border-left: 1px solid var(--mgm-border); }
#mg-members .mgm-seg button.is-active { background: var(--mgm-accent-soft); color: var(--mgm-accent); font-weight: 600; }
#mg-members .mgm-dates { display: inline-flex; align-items: center; gap: 6px; }
#mg-members .mgm-dates input { width: 140px; }
#mg-members .mgm-filter-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--mgm-border); }
/* 회원 그룹 — 항상 펼쳐 두는 칩 줄 */
#mg-members .mgm-groups { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 12px; padding-top: 12px; border-top: 1px dashed var(--mgm-border); }
#mg-members .mgm-groups-label { font-size: 12px; color: var(--mgm-muted); margin-right: 4px; }
#mg-members .mgm-group-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 13px; }
#mg-members .mgm-group-chip em { font-style: normal; font-size: 12px; color: var(--mgm-muted); font-variant-numeric: tabular-nums; }
#mg-members .mgm-group-chip.is-active { background: var(--mgm-accent); border-color: var(--mgm-accent); color: #fff; }
#mg-members .mgm-group-chip.is-active em { color: rgba(255,255,255,.85); }
#mg-members .mgm-group-chip.is-empty { opacity: .45; }
#mg-members td.mgm-group-cell { white-space: normal; min-width: 260px; max-width: 360px; }
#mg-members .mgm-group-badge { display: inline-block; margin: 1px 4px 1px 0; padding: 1px 8px; border-radius: 999px; font-size: 12px; background: #eef1f6; color: #3b4352; white-space: nowrap; }
#mg-members .mgm-active { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 12px; min-height: 28px; }
#mg-members .mgm-active-chip { display: inline-flex; align-items: center; gap: 4px; padding: 3px 4px 3px 10px; border-radius: 999px; background: var(--mgm-accent-soft); color: var(--mgm-accent); font-size: 12px; }
#mg-members .mgm-active-chip b { font-weight: 600; }
#mg-members .mgm-active-chip button { border: 0; background: none; color: inherit; padding: 0 6px; font-size: 14px; line-height: 1; }
/* 드롭다운(직접 그림) — 브라우저 기본 datalist 는 모양을 바꿀 수 없어 쓰지 않는다 */
#mg-members .mgm-combo { position: relative; }
#mg-members .mgm-combo input { padding-right: 30px; }
#mg-members .mgm-combo-toggle {
position: absolute; right: 1px; top: 1px; bottom: 1px; width: 28px; padding: 0; border: 0; border-radius: 0 8px 8px 0;
background: none; color: var(--mgm-muted); display: flex; align-items: center; justify-content: center;
}
#mg-members .mgm-combo-toggle svg { width: 12px; height: 12px; transition: transform .15s; }
#mg-members .mgm-combo.is-open .mgm-combo-toggle svg { transform: rotate(180deg); }
#mg-members .mgm-combo.is-open input { border-color: var(--mgm-accent); }
#mg-members .mgm-pop {
position: absolute; top: calc(100% + 4px); left: 0; z-index: 30;
min-width: 100%; width: max-content; max-width: min(420px, 90vw); max-height: 288px; overflow-y: auto;
background: #fff; border: 1px solid var(--mgm-border); border-radius: 10px;
box-shadow: 0 8px 24px rgba(20, 26, 35, .12); padding: 4px;
}
#mg-members .mgm-pop.is-right { left: auto; right: 0; }
#mg-members .mgm-opt {
display: flex; align-items: center; gap: 16px; width: 100%; text-align: left;
border: 0; border-radius: 6px; background: none; padding: 7px 10px; font-size: 13px; color: var(--mgm-text);
}
#mg-members .mgm-opt span:first-child { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#mg-members .mgm-opt span + span { flex: none; color: var(--mgm-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
#mg-members .mgm-opt.is-active, #mg-members .mgm-opt:hover { background: var(--mgm-bg); }
#mg-members .mgm-opt.is-selected span:first-child { color: var(--mgm-accent); font-weight: 600; }
#mg-members .mgm-opt-empty { padding: 8px 10px; font-size: 13px; color: var(--mgm-muted); }
/* 표 */
#mg-members .mgm-table-wrap { overflow-x: auto; }
#mg-members table { width: 100%; border-collapse: collapse; font-size: 13px; }
#mg-members th, #mg-members td { padding: 9px 10px; border-bottom: 1px solid var(--mgm-border); text-align: left; white-space: nowrap; vertical-align: top; }
#mg-members th { font-size: 12px; color: var(--mgm-muted); font-weight: 600; background: #fafbfc; position: sticky; top: 0; }
#mg-members td.mgm-wrap { white-space: normal; min-width: 140px; }
#mg-members .mgm-empty { padding: 32px; text-align: center; color: var(--mgm-muted); }
/* 연동 관리 */
#mg-members .mgm-dl { display: grid; grid-template-columns: 140px 1fr; gap: 10px 16px; margin: 0; }
#mg-members .mgm-dl dt { color: var(--mgm-muted); font-size: 13px; }
#mg-members .mgm-dl dd { margin: 0; }
@media (max-width: 1200px) {
#mg-members .mgm-filter-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1080px) {
#mg-members .mgm-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#mg-members .mgm-grid-2 { grid-template-columns: minmax(0, 1fr); }
}
/* 좁은 화면: 왼쪽 메뉴를 위쪽 가로 탭으로 */
@media (max-width: 860px) {
#mg-members .mgm-shell { grid-template-columns: minmax(0, 1fr); border-radius: 0; background: var(--mgm-bg); }
#mg-members .mgm-side { height: auto; overflow: visible; z-index: 5; }
#mg-members .mgm-filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#mg-members .mgm-dates { width: 100%; }
#mg-members .mgm-dates input { flex: 1; width: auto; }
#mg-members .mgm-seg { width: 100%; overflow-x: auto; }
#mg-members .mgm-seg button { flex: 1 0 auto; }
/* 1행: 브랜드 · 관리자/로그아웃, 2행: 메뉴 탭 (넘치면 가로 스크롤) */
#mg-members .mgm-side { flex-direction: row; flex-wrap: wrap; align-items: center; padding: 12px 14px 8px; gap: 10px; }
#mg-members .mgm-brand { flex: none; white-space: nowrap; padding: 0; }
#mg-members .mgm-brand span { display: none; }
#mg-members .mgm-side-foot { margin: 0 0 0 auto; padding: 0; border: 0; display: flex; align-items: center; gap: 8px; white-space: nowrap; }
#mg-members .mgm-side-foot button { margin: 0; width: auto; padding: 4px 10px; }
#mg-members .mgm-nav { order: 3; flex: 1 0 100%; flex-direction: row; overflow-x: auto; gap: 4px; }
#mg-members .mgm-nav button { width: auto; flex: none; padding: 7px 12px; font-size: 13px; }
#mg-members .mgm-nav svg { display: none; }
#mg-members .mgm-main { padding: 16px; }
#mg-members .mgm-bar { grid-template-columns: minmax(70px, 120px) 1fr 48px; }
#mg-members .mgm-dl { grid-template-columns: 1fr; gap: 2px 0; }
#mg-members .mgm-dl dd { margin-bottom: 8px; }
}
@media (max-width: 520px) {
#mg-members .mgm-kpis { grid-template-columns: minmax(0, 1fr); }
}
</style>
<!-- 로그인 -->
<div class="mgm-login-wrap" data-role="login" hidden>
<section class="mgm-card mgm-login">
<h3>관리자 로그인</h3>
<p class="mgm-muted">회원 정보는 백엔드 관리자 계정으로 로그인해야 볼 수 있습니다.</p>
<div class="mgm-notice" data-role="login-notice" hidden></div>
<form data-role="login-form">
<label class="mgm-field">아이디<input name="username" autocomplete="username" required maxlength="50"></label>
<label class="mgm-field">비밀번호<input name="password" type="password" autocomplete="current-password" required maxlength="72"></label>
<button class="mgm-primary" type="submit">로그인</button>
</form>
</section>
</div>
<!-- 대시보드 -->
<div class="mgm-shell" data-role="app" hidden>
<aside class="mgm-side">
<div class="mgm-brand">
<strong>자본주의가이드</strong>
<span>회원 유입 대시보드</span>
</div>
<!-- 위젯 위에 사이트 헤더가 있어도 항상 보이도록 위쪽에 둔다 -->
<div class="mgm-side-foot">
<span data-role="admin-name"></span>
<button type="button" data-action="logout">로그아웃</button>
</div>
<nav class="mgm-nav" aria-label="관리 메뉴">
<button type="button" data-view="overview">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
개요
</button>
<button type="button" data-view="members">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
회원 목록
</button>
<button type="button" data-view="applications">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1"/><path d="m9 13 2 2 4-4"/></svg>
신청 내역
</button>
<button type="button" data-view="integration">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
연동 관리
</button>
</nav>
</aside>
<main class="mgm-main">
<header class="mgm-topbar">
<div>
<h1 data-role="view-title">개요</h1>
<p class="mgm-muted" data-role="view-desc"></p>
</div>
<span class="mgm-spacer"></span>
<span class="mgm-sync-pill" data-role="sync-pill">상태 확인 중</span>
<button type="button" class="mgm-primary" data-action="sync" hidden>지금 동기화</button>
</header>
<div class="mgm-notice" data-role="notice" hidden></div>
<!-- 개요 -->
<section data-panel="overview" hidden>
<div class="mgm-kpis">
<button type="button" class="mgm-card mgm-kpi" data-goto="members" data-preset="all">
<span class="mgm-kpi-label">전체 회원</span>
<span class="mgm-kpi-value" data-kpi="total">-</span>
<span class="mgm-kpi-sub">동기화된 회원 수</span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="members" data-preset="tracked">
<span class="mgm-kpi-label">유입경로 확보</span>
<span class="mgm-kpi-value" data-kpi="tracked">-</span>
<span class="mgm-kpi-sub" data-kpi="tracked-rate">가입 때 기록된 회원</span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="members" data-preset="7">
<span class="mgm-kpi-label">최근 7일 가입</span>
<span class="mgm-kpi-value" data-kpi="recent">-</span>
<span class="mgm-kpi-sub" data-kpi="recent-tracked"></span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="integration">
<span class="mgm-kpi-label">마지막 동기화</span>
<span class="mgm-kpi-value" data-kpi="synced" style="font-size:18px;margin-top:10px">-</span>
<span class="mgm-kpi-sub" data-kpi="synced-sub"></span>
</button>
</div>
<div class="mgm-grid-2">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>유입원 상위</h3>
<span class="mgm-muted">전체 기간 · 가입 때 기록 기준 · 누르면 해당 회원 목록</span>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="members" data-preset="tracked">회원 보기</button>
</div>
<div class="mgm-bars" data-role="overview-bars"></div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>최근 가입</h3>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="members" data-preset="all">전체 보기</button>
</div>
<div class="mgm-table-wrap">
<table>
<thead><tr><th>가입일시</th><th>이름</th><th>기록</th><th>유입원</th><th>매체</th><th>캠페인</th></tr></thead>
<tbody data-role="recent-rows"></tbody>
</table>
</div>
</section>
</div>
</section>
<!-- 회원 목록 -->
<section data-panel="members" hidden>
<section class="mgm-card" style="margin-bottom:16px">
<form data-role="filter-form" autocomplete="off">
<div class="mgm-filter-top">
<label class="mgm-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input type="search" name="q" maxlength="100" placeholder="이름, 아이디, 이메일, 연락처로 검색" aria-label="회원 검색">
</label>
<div class="mgm-seg" role="group" aria-label="가입 기간">
<button type="button" data-range="all" class="is-active">전체</button>
<button type="button" data-range="today">오늘</button>
<button type="button" data-range="7">7일</button>
<button type="button" data-range="30">30일</button>
<button type="button" data-range="month">이번 달</button>
</div>
<div class="mgm-dates">
<input type="date" name="from" aria-label="가입일 시작">
<span class="mgm-muted">~</span>
<input type="date" name="to" aria-label="가입일 끝">
</div>
</div>
<div class="mgm-groups" role="group" aria-label="회원 그룹">
<span class="mgm-groups-label">회원 그룹</span>
<span data-role="group-chips" class="mgm-row" style="gap:6px"><span class="mgm-muted">불러오는 중…</span></span>
</div>
<div class="mgm-filter-grid">
<label class="mgm-field">기록 상태
<select name="inflowStatus">
<option value="">전체</option>
<option value="at_join">가입 때 기록</option>
<option value="after_join">가입 후 기록</option>
<option value="none">기록 없음</option>
</select>
</label>
<div class="mgm-field">
<label for="mgm-f-source">유입원</label>
<div class="mgm-combo" data-combo="source">
<input id="mgm-f-source" name="source" placeholder="전체" maxlength="100" autocomplete="off"
role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="mgm-pop-source">
<button type="button" class="mgm-combo-toggle" tabindex="-1" aria-label="유입원 목록 열기">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="mgm-pop" id="mgm-pop-source" role="listbox" hidden></div>
</div>
</div>
<div class="mgm-field">
<label for="mgm-f-medium">매체</label>
<div class="mgm-combo" data-combo="medium">
<input id="mgm-f-medium" name="medium" placeholder="전체" maxlength="100" autocomplete="off"
role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="mgm-pop-medium">
<button type="button" class="mgm-combo-toggle" tabindex="-1" aria-label="매체 목록 열기">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="mgm-pop" id="mgm-pop-medium" role="listbox" hidden></div>
</div>
</div>
<div class="mgm-field">
<label for="mgm-f-campaign">캠페인</label>
<div class="mgm-combo" data-combo="campaign">
<input id="mgm-f-campaign" name="campaign" placeholder="전체" maxlength="100" autocomplete="off"
role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="mgm-pop-campaign">
<button type="button" class="mgm-combo-toggle" tabindex="-1" aria-label="캠페인 목록 열기">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="mgm-pop" id="mgm-pop-campaign" role="listbox" hidden></div>
</div>
</div>
<div class="mgm-field">
<label for="mgm-f-content">콘텐츠</label>
<div class="mgm-combo" data-combo="content">
<input id="mgm-f-content" name="content" placeholder="전체" maxlength="100" autocomplete="off"
role="combobox" aria-expanded="false" aria-autocomplete="list" aria-controls="mgm-pop-content">
<button type="button" class="mgm-combo-toggle" tabindex="-1" aria-label="콘텐츠 목록 열기">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"/></svg>
</button>
<div class="mgm-pop" id="mgm-pop-content" role="listbox" hidden></div>
</div>
</div>
</div>
<div class="mgm-active">
<span class="mgm-muted" data-role="active-empty">적용된 필터 없음</span>
<span data-role="active-filters" class="mgm-row" style="gap:6px"></span>
<button type="button" class="mgm-link" data-action="reset" hidden>모두 지우기</button>
</div>
</form>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>회원</h3>
<span class="mgm-muted" data-role="total"></span>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr>
<th>가입일시</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th><th>가입 방식</th><th>회원 그룹</th>
<th>기록</th><th>유입원</th><th>매체</th><th>캠페인</th><th>콘텐츠</th><th>검색어</th>
<th>첫 랜딩</th><th>최초 유입일</th><th>최종 유입</th>
</tr>
</thead>
<tbody data-role="rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="empty" hidden>조건에 맞는 회원이 없습니다.</div>
<div class="mgm-row" style="justify-content:center;margin-top:12px">
<button type="button" data-action="more" hidden>더 보기</button>
</div>
</section>
</section>
<!-- 신청 내역 -->
<section data-panel="applications" hidden>
<section class="mgm-card" style="margin-bottom:16px">
<form data-role="app-filter" autocomplete="off">
<div class="mgm-filter-top">
<label class="mgm-search">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
<input type="search" name="q" maxlength="100" placeholder="이름, 아이디, 이메일, 연락처로 검색" aria-label="신청자 검색">
</label>
<div class="mgm-seg" role="group" aria-label="신청 기간">
<button type="button" data-app-range="all" class="is-active">전체</button>
<button type="button" data-app-range="today">오늘</button>
<button type="button" data-app-range="7">7일</button>
<button type="button" data-app-range="30">30일</button>
<button type="button" data-app-range="month">이번 달</button>
</div>
</div>
<div class="mgm-groups" role="group" aria-label="신청 상품">
<span class="mgm-groups-label">신청 상품</span>
<span data-role="app-products" class="mgm-row" style="gap:6px"><span class="mgm-muted">불러오는 중…</span></span>
</div>
</form>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청</h3>
<span class="mgm-muted" data-role="app-total"></span>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr>
<th>신청일시</th><th>상품</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th>
<th>오픈카톡</th><th>신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
</tr>
</thead>
<tbody data-role="app-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="app-empty" hidden>신청 기록이 없습니다.</div>
<div class="mgm-row" style="justify-content:center;margin-top:12px">
<button type="button" data-action="app-more" hidden>더 보기</button>
</div>
</section>
</section>
<!-- 연동 관리 -->
<section data-panel="integration" hidden>
<section class="mgm-card" style="margin-bottom:16px">
<div class="mgm-card-head">
<h3>아임웹 연결</h3>
<span class="mgm-badge" data-role="conn-badge">확인 중</span>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-primary" data-action="connect" hidden>아임웹 연결</button>
<button type="button" class="mgm-link" data-action="disconnect" hidden>연결 해제</button>
</div>
<dl class="mgm-dl" data-role="conn-detail"></dl>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>회원 동기화</h3>
<span class="mgm-muted">아임웹 회원 전체를 주기적으로 받아옵니다</span>
</div>
<dl class="mgm-dl" data-role="sync-detail"></dl>
</section>
</section>
</main>
</div>
<script>
(function () {
'use strict';
/* ===================== 설정 ===================== */
var API_BASE = /^(localhost|127\.0\.0\.1)$/.test(location.hostname)
? 'http://localhost:3100/api'
: 'https://moneyguide.wandergo.net/api'; // 운영 백엔드 주소
var PAGE_SIZE = 50;
var RECENT_SIZE = 8;
var OVERVIEW_TOP = 8;
var SYNC_POLL_MS = 3000;
var KST_OFFSET_MS = 9 * 3600000;
var DAY_MS = 86400000;
var SESSION_KEY = 'mg_admin_session';
var VIEW_KEY = 'mg_admin_view';
var VIEWS = {
overview: { title: '개요', desc: '회원 수와 유입 현황을 한눈에 봅니다' },
members: { title: '회원 목록', desc: '가입 기간·유입경로로 회원을 찾습니다' },
applications: { title: '신청 내역', desc: '누가 어떤 강의에 신청했는지 봅니다 (신청 완료 화면 기준)' },
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' }
};
var root = document.getElementById('mg-members');
function $(sel) { return root.querySelector(sel); }
function $all(sel) { return root.querySelectorAll(sel); }
function each(list, fn) { Array.prototype.forEach.call(list, fn); }
var state = {
admin: null,
view: 'overview',
cursor: null,
listSeq: 0,
app: { range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true },
suggestLoaded: false,
suggestions: {},
groupCode: '', // 선택한 회원 그룹 (폼 reset 으로 지워지지 않게 상태로 둔다)
groups: null, // [{ code, title, memberCount }]
appliedFilters: {},
loading: false,
pollTimer: null,
lastRunStatus: null,
status: null
};
/* ===================== 공통 ===================== */
// 회원 이름 등은 사용자가 입력한 값이므로 화면에 넣기 전에 반드시 이스케이프한다
function esc(value) {
if (value === null || value === undefined || value === '') return '';
return String(value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function dash(value) { return value ? esc(value) : '<span class="mgm-muted">-</span>'; }
function num(n) { return Number(n || 0).toLocaleString(); }
function pad(n) { return n < 10 ? '0' + n : String(n); }
// ISO → KST 'YYYY-MM-DD HH:mm'
function kstDateTime(iso) {
if (!iso) return '';
var d = new Date(Date.parse(iso) + KST_OFFSET_MS);
return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate()) +
' ' + pad(d.getUTCHours()) + ':' + pad(d.getUTCMinutes());
}
// 오늘(KST) 'YYYY-MM-DD'
function kstToday(offsetDays) {
var d = new Date(Date.now() + KST_OFFSET_MS + (offsetDays || 0) * DAY_MS);
return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate());
}
// KST 날짜의 0시 → UTC ISO
function kstDayStartIso(ymd, plusDays) {
var p = ymd.split('-');
var t = Date.UTC(+p[0], +p[1] - 1, +p[2]) - KST_OFFSET_MS + (plusDays || 0) * DAY_MS;
return new Date(t).toISOString();
}
// 기간 버튼 → { from, to } (KST 날짜, 전체면 빈 값)
function rangeDates(range) {
var today = kstToday(0);
if (range === 'today') return { from: today, to: today };
if (range === '7') return { from: kstToday(-6), to: today };
if (range === '30') return { from: kstToday(-29), to: today };
if (range === 'month') return { from: today.slice(0, 8) + '01', to: today };
return { from: '', to: '' };
}
function rangeQuery(range) {
var r = rangeDates(range), q = {};
if (r.from) q.joinedFrom = kstDayStartIso(r.from);
if (r.to) q.joinedTo = kstDayStartIso(r.to, 1);
return q;
}
function toQuery(obj) {
var parts = [];
Object.keys(obj).forEach(function (k) {
if (obj[k] !== undefined && obj[k] !== null && obj[k] !== '') {
parts.push(encodeURIComponent(k) + '=' + encodeURIComponent(obj[k]));
}
});
return parts.length ? '?' + parts.join('&') : '';
}
function setNotice(el, message, kind) {
if (!message) { el.hidden = true; return; }
el.className = 'mgm-notice ' + (kind === 'ok' ? 'is-ok' : 'is-error');
el.innerHTML = message;
el.hidden = false;
}
function notice(message, kind) { setNotice($('[data-role="notice"]'), message, kind); }
function errorText(err) {
var trace = err && err.traceId ? ' <span class="mgm-trace">(추적 ID ' + esc(err.traceId) + ')</span>' : '';
return esc((err && err.message) || '요청을 처리하지 못했습니다.') + trace;
}
function errorNotice(err) { notice(errorText(err), 'error'); }
function storageGet(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
function storageSet(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* 무시 */ } }
/* ===================== 세션 토큰 ===================== */
// sessionStorage 를 못 쓰는 환경(사생활 보호 모드 등)에서는 메모리에만 둔다
var memorySession = null;
function loadSession() {
try { var raw = sessionStorage.getItem(SESSION_KEY); return raw ? JSON.parse(raw) : memorySession; }
catch (e) { return memorySession; }
}
function saveSession(session) {
memorySession = session;
try {
if (session) sessionStorage.setItem(SESSION_KEY, JSON.stringify(session));
else sessionStorage.removeItem(SESSION_KEY);
} catch (e) { /* 메모리 값으로 계속 동작 */ }
}
function storeIssued(issued) {
saveSession({
accessToken: issued.accessToken,
refreshToken: issued.refreshToken,
refreshTokenExpiresAt: issued.refreshTokenExpiresAt
});
return issued.admin;
}
/* ===================== API ===================== */
function rawRequest(method, path, body, withToken) {
var headers = {};
if (body) headers['Content-Type'] = 'application/json';
var session = loadSession();
if (withToken !== false && session) headers.Authorization = 'Bearer ' + session.accessToken;
return fetch(API_BASE + path, {
method: method,
credentials: 'omit', // 쿠키는 쓰지 않는다 — 인증은 Authorization 헤더로만
headers: headers,
body: body ? JSON.stringify(body) : undefined
}).then(function (res) {
if (res.status === 204) return { ok: true, status: 204, data: null };
return res.json().then(function (json) {
if (json && json.success) return { ok: true, status: res.status, data: json.data };
var e = (json && json.error) || {};
return { ok: false, status: res.status, error: { code: e.code, message: e.message, traceId: e.traceId } };
}, function () {
return { ok: false, status: res.status, error: { code: 'SYS_001', message: '서버 응답을 읽지 못했습니다.' } };
});
}, function () {
return { ok: false, status: 0, error: { code: 'SYS_001', message: '백엔드 서버에 연결하지 못했습니다.' } };
});
}
// 동시에 여러 요청이 401 을 받아도 갱신 요청은 한 번만 보낸다
var renewing = null;
function renewSession() {
var session = loadSession();
if (!session || !session.refreshToken || Date.parse(session.refreshTokenExpiresAt) <= Date.now()) {
return Promise.resolve(false);
}
if (!renewing) {
renewing = rawRequest('POST', '/admin/sessions/renewal', { refreshToken: session.refreshToken }, false)
.then(function (r) {
if (r.ok) storeIssued(r.data); else saveSession(null);
return r.ok;
})
.then(function (ok) { renewing = null; return ok; });
}
return renewing;
}
// 401 이면 세션 갱신을 한 번 시도하고 다시 요청한다. 갱신도 실패하면 로그인 화면.
function api(method, path, body) {
return rawRequest(method, path, body).then(function (r) {
if (r.status !== 401) return r;
return renewSession().then(function (ok) {
if (!ok) { showLogin(); return r; }
return rawRequest(method, path, body);
});
}).then(function (r) {
if (!r.ok) throw r.error;
return r.data;
});
}
/* ===================== 로그인 ===================== */
function showLogin() {
stopPolling();
saveSession(null);
state.admin = null;
$('[data-role="app"]').hidden = true;
$('[data-role="login"]').hidden = false;
}
function showApp(admin) {
state.admin = admin;
$('[data-role="login"]').hidden = true;
$('[data-role="app"]').hidden = false;
$('[data-role="admin-name"]').textContent = admin.username + ' 님';
loadStatus().catch(errorNotice);
var saved = storageGet(VIEW_KEY);
go(VIEWS[saved] ? saved : 'overview');
}
$('[data-role="login-form"]').addEventListener('submit', function (e) {
e.preventDefault();
var form = e.target;
var loginNotice = $('[data-role="login-notice"]');
setNotice(loginNotice, null);
rawRequest('POST', '/admin/sessions', {
username: form.username.value.trim(),
password: form.password.value
}, false).then(function (r) {
form.password.value = '';
if (!r.ok) {
setNotice(loginNotice, r.status === 401 ? '아이디 또는 비밀번호가 올바르지 않습니다.' : errorText(r.error), 'error');
return;
}
showApp(storeIssued(r.data));
});
});
/* ===================== 화면 전환 ===================== */
function go(view) {
state.view = view;
storageSet(VIEW_KEY, view);
each($all('[data-view]'), function (b) { b.classList.toggle('is-active', b.getAttribute('data-view') === view); });
each($all('[data-panel]'), function (p) { p.hidden = p.getAttribute('data-panel') !== view; });
$('[data-role="view-title"]').textContent = VIEWS[view].title;
$('[data-role="view-desc"]').textContent = VIEWS[view].desc;
loadView(view);
}
function loadView(view) {
if (view === 'overview') loadOverview();
else if (view === 'members') loadMembers(true);
else if (view === 'applications') { state.app.productsStale = true; loadApplications(true); }
else if (view === 'integration') renderIntegration();
}
each($all('[data-view]'), function (b) {
b.addEventListener('click', function () { notice(null); go(b.getAttribute('data-view')); });
});
// 개요 카드·링크 → 다른 화면 (필요하면 필터를 미리 채운다)
root.addEventListener('click', function (e) {
var link = e.target.closest ? e.target.closest('[data-goto]') : null;
if (!link || !root.contains(link)) return;
var target = link.getAttribute('data-goto');
var preset = link.getAttribute('data-preset');
if (target === 'members' && preset) {
var form = $('[data-role="filter-form"]');
form.reset();
state.groupCode = ''; // 그룹은 폼이 아니라 상태에 있다
var range = preset === '7' ? '7' : 'all';
var r = rangeDates(range);
form.from.value = r.from; form.to.value = r.to;
if (preset === 'tracked') form.inflowStatus.value = 'at_join';
setRangeChip(range);
}
notice(null);
go(target);
});
/* ===================== 연동 상태 ===================== */
function syncPillText(s) {
if (!s.isConfigured) return '서버 설정 필요';
if (!s.isConnected) return '아임웹 미연결';
var run = s.latestRun;
if (run && run.status === 'running') return '동기화 중… ' + num(run.fetchedCount) + '명';
if (run && run.status === 'failed') return '마지막 동기화 실패';
return s.latestSucceededRun ? '동기화 ' + kstDateTime(s.latestSucceededRun.finishedAt) : '동기화 전';
}
function loadStatus() {
return api('GET', '/admin/imweb/connection').then(function (s) {
state.status = s;
var running = s.latestRun && s.latestRun.status === 'running';
$('[data-role="sync-pill"]').textContent = syncPillText(s);
var syncBtn = $('[data-action="sync"]');
syncBtn.hidden = !s.isConnected;
syncBtn.disabled = running;
if (state.view === 'integration') renderIntegration();
if (state.view === 'overview') renderOverviewStatus();
// 동기화가 끝나면 지금 보고 있는 화면을 새로 불러온다
var prev = state.lastRunStatus;
state.lastRunStatus = s.latestRun ? s.latestRun.status : null;
if (running) startPolling();
else {
stopPolling();
if (prev === 'running') { state.suggestLoaded = false; state.groupsStale = true; loadView(state.view); }
}
return s;
});
}
function startPolling() {
if (state.pollTimer) return;
state.pollTimer = setInterval(function () { loadStatus().catch(errorNotice); }, SYNC_POLL_MS);
}
function stopPolling() {
if (state.pollTimer) { clearInterval(state.pollTimer); state.pollTimer = null; }
}
var RUN_STATUS = { running: '진행 중', succeeded: '성공', failed: '실패' };
var RUN_TRIGGER = { manual: '수동', schedule: '예약' };
function dl(pairs) {
return pairs.map(function (p) { return '<dt>' + esc(p[0]) + '</dt><dd>' + p[1] + '</dd>'; }).join('');
}
function renderIntegration() {
var s = state.status;
if (!s) return;
var badge = $('[data-role="conn-badge"]');
if (!s.isConfigured) { badge.className = 'mgm-badge is-danger'; badge.textContent = '서버 설정 필요'; }
else if (!s.isConnected) { badge.className = 'mgm-badge is-warn'; badge.textContent = '미연결'; }
else { badge.className = 'mgm-badge is-ok'; badge.textContent = '연결됨'; }
$('[data-action="connect"]').hidden = !s.isConfigured || s.isConnected;
$('[data-action="disconnect"]').hidden = !s.isConnected;
$('[data-role="conn-detail"]').innerHTML = dl([
['연결 시각', s.connectedAt ? esc(kstDateTime(s.connectedAt)) : '<span class="mgm-muted">-</span>'],
['동기화된 회원', esc(num(s.memberCount)) + '명']
]);
var run = s.latestRun, ok = s.latestSucceededRun;
var runText = run
? esc(RUN_STATUS[run.status] || run.status) + ' · ' + esc(RUN_TRIGGER[run.trigger] || run.trigger) +
' · ' + esc(kstDateTime(run.startedAt)) + ' · ' + esc(num(run.fetchedCount)) + '명' +
(run.errorCode ? ' <span class="mgm-badge is-danger">' + esc(run.errorCode) + '</span>' : '')
: '<span class="mgm-muted">아직 없음</span>';
$('[data-role="sync-detail"]').innerHTML = dl([
['최근 실행 (시작)', runText],
['마지막 성공', ok ? esc(kstDateTime(ok.finishedAt)) + ' · ' + esc(num(ok.fetchedCount)) + '명' +
(ok.removedCount ? ' · 탈퇴 정리 ' + esc(num(ok.removedCount)) + '명' : '') : '<span class="mgm-muted">-</span>'],
['자동 동기화', '서버 설정 주기마다 (기본 60분, IMWEB_SYNC_INTERVAL_MINUTES)']
]);
}
root.addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('[data-action]') : null;
if (!btn || !root.contains(btn)) return;
var action = btn.getAttribute('data-action');
if (action === 'logout') {
// 서버에서 토큰을 무효화한 뒤 화면의 토큰도 지운다 (이미 만료됐어도 화면은 로그아웃된다)
rawRequest('DELETE', '/admin/sessions').then(function () { saveSession(null); showLogin(); });
} else if (action === 'connect') {
api('POST', '/admin/imweb/authorizations').then(function (r) {
location.href = r.authorizationUrl; // 아임웹 동의 화면 → 끝나면 이 페이지로 돌아온다
}).catch(errorNotice);
} else if (action === 'disconnect') {
if (!window.confirm('아임웹 연결을 해제할까요? 동기화된 회원 목록은 남아 있습니다.')) return;
api('DELETE', '/admin/imweb/connection').then(loadStatus).catch(errorNotice);
} else if (action === 'sync') {
btn.disabled = true;
api('POST', '/admin/imweb/sync-runs').then(function () {
notice('동기화를 시작했습니다. 끝나면 화면이 자동으로 새로고침됩니다.', 'ok');
return loadStatus();
}).catch(function (err) { btn.disabled = false; errorNotice(err); });
} else if (action === 'more') {
loadMembers(false);
} else if (action === 'app-more') {
loadApplications(false);
} else if (action === 'reset') {
resetFilters();
applyFilters();
}
});
/* ===================== 막대 그래프 ===================== */
function barsHtml(items, clickable) {
if (!items.length) return '<div class="mgm-muted">집계할 데이터가 없습니다.</div>';
var max = items.reduce(function (m, i) { return Math.max(m, i.count); }, 0) || 1;
return items.map(function (i) {
var label = i.key === null ? '(값 없음)' : esc(i.key);
var pct = Math.max(2, Math.round((i.count / max) * 100));
var disabled = !clickable || i.key === null;
return '<button type="button" class="mgm-bar" data-key="' + (i.key === null ? '' : esc(i.key)) + '"' +
(disabled ? ' disabled' : '') + ' title="' + label + '">' +
'<span class="mgm-bar-label">' + label + '</span>' +
'<span class="mgm-bar-track"><span class="mgm-bar-fill" style="width:' + pct + '%"></span></span>' +
'<span class="mgm-bar-count">' + num(i.count) + '</span></button>';
}).join('');
}
/* ===================== 개요 ===================== */
function setKpi(name, value) { var el = $('[data-kpi="' + name + '"]'); if (el) el.textContent = value; }
function renderOverviewStatus() {
var s = state.status;
if (!s) return;
setKpi('total', num(s.memberCount));
var ok = s.latestSucceededRun;
setKpi('synced', ok ? kstDateTime(ok.finishedAt) : '-');
setKpi('synced-sub', syncPillText(s));
}
function loadOverview() {
renderOverviewStatus();
var week = rangeQuery('7');
api('GET', '/admin/members/inflow-stats' + toQuery({ groupBy: 'source' })).then(function (s) {
var c = s.statusCounts, total = c.atJoin + c.afterJoin + c.none;
setKpi('tracked', num(c.atJoin));
setKpi('tracked-rate', '전체의 ' + (total ? Math.round((c.atJoin / total) * 1000) / 10 : 0) + '% · 가입 때 기록');
$('[data-role="overview-bars"]').innerHTML = barsHtml(s.items.slice(0, OVERVIEW_TOP), true);
}).catch(errorNotice);
api('GET', '/admin/members/inflow-stats' + toQuery(Object.assign({ groupBy: 'source' }, week))).then(function (s) {
var c = s.statusCounts;
setKpi('recent', num(c.atJoin + c.afterJoin + c.none));
setKpi('recent-tracked', '유입경로 확보 ' + num(c.atJoin) + '명');
}).catch(errorNotice);
api('GET', '/admin/members' + toQuery({ limit: RECENT_SIZE })).then(function (page) {
var rows = page.items.map(function (m) {
var inf = m.inflow;
return '<tr><td>' + esc(kstDateTime(m.joinTime)) + '</td><td>' + dash(m.name) + '</td>' +
'<td>' + (STATUS_BADGE[inf.status] || '') + '</td><td>' + dash(inf.source) + '</td>' +
'<td>' + dash(inf.medium) + '</td><td class="mgm-wrap">' + dash(inf.campaign) + '</td></tr>';
}).join('');
$('[data-role="recent-rows"]').innerHTML = rows ||
'<tr><td colspan="6" class="mgm-empty">회원이 없습니다.</td></tr>';
}).catch(errorNotice);
}
// 개요의 유입원 막대 → 그 유입원으로 회원 목록
$('[data-role="overview-bars"]').addEventListener('click', function (e) {
var bar = e.target.closest ? e.target.closest('.mgm-bar') : null;
if (!bar || bar.disabled) return;
openMembersWith('source', bar.getAttribute('data-key'), 'all');
});
/* ===================== 회원 목록 필터 ===================== */
// 버튼 없이 바로 적용한다 — 선택형은 바꾸는 즉시, 검색어는 입력을 멈추면
var FILTER_KEYS = ['inflowStatus', 'source', 'medium', 'campaign', 'content', 'q'];
var SEARCH_DEBOUNCE_MS = 350;
var SUGGEST_FIELDS = ['source', 'medium', 'campaign', 'content'];
var FILTER_LABEL = { q: '검색', inflowStatus: '기록', source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
var STATUS_LABEL = { at_join: '가입 때 기록', after_join: '가입 후 기록', none: '기록 없음' };
var filterForm = $('[data-role="filter-form"]');
function currentFilters() {
var f = {};
if (filterForm.from.value) f.joinedFrom = kstDayStartIso(filterForm.from.value);
if (filterForm.to.value) f.joinedTo = kstDayStartIso(filterForm.to.value, 1); // 끝 날짜 하루 전체 포함
FILTER_KEYS.forEach(function (k) {
var v = filterForm[k].value.trim();
if (v) f[k] = v;
});
if (state.groupCode) f.groupCode = state.groupCode;
return f;
}
function setRangeChip(range) {
each($all('[data-range]'), function (b) { b.classList.toggle('is-active', b.getAttribute('data-range') === range); });
}
// 날짜를 직접 고치면 그 날짜가 기간 버튼 중 하나와 같은지 다시 맞춘다
function syncRangeChip() {
var match = null;
['all', 'today', '7', '30', 'month'].forEach(function (range) {
var r = rangeDates(range);
if (!match && r.from === filterForm.from.value && r.to === filterForm.to.value) match = range;
});
setRangeChip(match);
}
// 적용 중인 필터를 칩으로 보여주고 × 로 하나씩 지울 수 있게 한다
function renderActiveFilters() {
var chips = [];
if (filterForm.from.value || filterForm.to.value) {
chips.push(['period', '기간', (filterForm.from.value || '처음') + ' ~ ' + (filterForm.to.value || '오늘')]);
}
if (state.groupCode) chips.push(['groupCode', '그룹', groupTitle(state.groupCode)]);
FILTER_KEYS.forEach(function (k) {
var v = filterForm[k].value.trim();
if (v) chips.push([k, FILTER_LABEL[k], k === 'inflowStatus' ? STATUS_LABEL[v] || v : v]);
});
renderGroupChips();
$('[data-role="active-filters"]').innerHTML = chips.map(function (c) {
return '<span class="mgm-active-chip">' + esc(c[1]) + ' <b>' + esc(c[2]) + '</b>' +
'<button type="button" data-clear="' + esc(c[0]) + '" aria-label="' + esc(c[1]) + ' 필터 지우기">×</button></span>';
}).join('');
$('[data-role="active-empty"]').hidden = chips.length > 0;
$('[data-action="reset"]').hidden = chips.length === 0;
}
function applyFilters() {
notice(null);
loadMembers(true);
}
// 마지막으로 적용한 드롭다운 값 — 같은 값으로 change 가 또 와도 다시 조회하지 않는다
function rememberApplied() {
SUGGEST_FIELDS.forEach(function (k) { state.appliedFilters[k] = filterForm[k].value.trim(); });
}
function resetFilters() {
filterForm.reset();
state.groupCode = '';
setRangeChip('all');
}
/* ----- 회원 그룹 칩 (항상 펼쳐 둔다) ----- */
function groupTitle(code) {
var found = (state.groups || []).filter(function (g) { return g.code === code; })[0];
return found ? found.title : code;
}
function renderGroupChips() {
var box = $('[data-role="group-chips"]');
if (!state.groups) return;
var total = state.status ? state.status.memberCount : null;
var chips = [{ code: '', title: '전체', memberCount: total }].concat(state.groups);
box.innerHTML = chips.map(function (g) {
var active = g.code === state.groupCode;
var empty = g.code && g.memberCount === 0;
return '<button type="button" class="mgm-group-chip' + (active ? ' is-active' : '') + (empty ? ' is-empty' : '') +
'" data-group-code="' + esc(g.code) + '" aria-pressed="' + active + '">' + esc(g.title) +
(g.memberCount === null || g.memberCount === undefined ? '' : ' <em>' + esc(num(g.memberCount)) + '</em>') + '</button>';
}).join('');
}
// 그룹 목록·인원 수 — 회원 목록에 들어올 때 한 번, 동기화가 끝나면 다시
function loadGroups() {
if (state.groups && !state.groupsStale) return;
state.groupsStale = false;
api('GET', '/admin/members/groups').then(function (groups) {
state.groups = groups;
renderGroupChips();
renderActiveFilters();
}).catch(function (err) {
state.groupsStale = true;
$('[data-role="group-chips"]').innerHTML = '<span class="mgm-muted">그룹을 불러오지 못했습니다</span>';
errorNotice(err);
});
}
// 같은 칩을 다시 누르면 해제(전체)
$('[data-role="group-chips"]').addEventListener('click', function (e) {
var chip = e.target.closest ? e.target.closest('[data-group-code]') : null;
if (!chip) return;
var code = chip.getAttribute('data-group-code');
state.groupCode = state.groupCode === code ? '' : code;
applyFilters();
});
each($all('[data-range]'), function (b) {
b.addEventListener('click', function () {
var range = b.getAttribute('data-range');
var r = rangeDates(range);
filterForm.from.value = r.from; filterForm.to.value = r.to;
setRangeChip(range);
applyFilters();
});
});
// 선택형·날짜·자동완성 값은 바뀌는 즉시 (텍스트 칸은 Enter·자동완성 선택·포커스 이동 시)
filterForm.addEventListener('change', function (e) {
if (e.target.name === 'q') return;
if (SUGGEST_FIELDS.indexOf(e.target.name) >= 0 && e.target.value.trim() === state.appliedFilters[e.target.name]) return;
if (e.target.name === 'from' || e.target.name === 'to') syncRangeChip();
applyFilters();
});
// 검색어는 입력을 멈추면 적용
var searchTimer = null;
filterForm.q.addEventListener('input', function () {
clearTimeout(searchTimer);
searchTimer = setTimeout(applyFilters, SEARCH_DEBOUNCE_MS);
});
filterForm.addEventListener('submit', function (e) {
e.preventDefault();
clearTimeout(searchTimer);
applyFilters();
});
$('[data-role="active-filters"]').addEventListener('click', function (e) {
var btn = e.target.closest ? e.target.closest('[data-clear]') : null;
if (!btn) return;
var key = btn.getAttribute('data-clear');
if (key === 'period') { filterForm.from.value = ''; filterForm.to.value = ''; setRangeChip('all'); }
else if (key === 'groupCode') state.groupCode = '';
else if (filterForm[key]) filterForm[key].value = '';
applyFilters();
});
// 유입원·매체·캠페인·콘텐츠 자동완성 — 실제로 있는 값만 고르게 한다 (화면 진입 때 한 번)
function loadSuggestions() {
if (state.suggestLoaded) return;
state.suggestLoaded = true;
SUGGEST_FIELDS.forEach(function (field) {
api('GET', '/admin/members/inflow-stats' + toQuery({ groupBy: field })).then(function (s) {
state.suggestions[field] = s.items.filter(function (i) { return i.key !== null; });
if (openCombo && openCombo.field === field) renderCombo(openCombo);
}).catch(function () { state.suggestLoaded = false; });
});
}
/* ----- 드롭다운 (유입원·매체·캠페인·콘텐츠) ----- */
var openCombo = null; // { field, el, input, pop, active }
function comboOptions(c) {
var all = state.suggestions[c.field] || [];
var typed = c.input.value.trim().toLowerCase();
// 이미 고른 값 그대로면 전체 목록을, 입력 중이면 걸러서 보여준다
var exact = all.some(function (i) { return i.key.toLowerCase() === typed; });
return typed && !exact
? all.filter(function (i) { return i.key.toLowerCase().indexOf(typed) >= 0; })
: all;
}
function renderCombo(c) {
var options = comboOptions(c);
c.options = [{ key: '', count: null }].concat(options); // 맨 위 `전체` = 필터 해제
if (c.active >= c.options.length) c.active = c.options.length - 1;
var current = c.input.value.trim();
var html = c.options.map(function (o, i) {
var selected = o.key === current;
return '<button type="button" class="mgm-opt' + (i === c.active ? ' is-active' : '') + (selected ? ' is-selected' : '') +
'" role="option" aria-selected="' + selected + '" data-index="' + i + '">' +
'<span>' + (o.key ? esc(o.key) : '전체') + '</span>' +
(o.count === null ? '<span></span>' : '<span>' + esc(num(o.count)) + '명</span>') + '</button>';
}).join('');
if (!options.length) {
html += '<div class="mgm-opt-empty">' + (state.suggestions[c.field] ? '일치하는 값이 없습니다 — Enter 로 입력한 값 그대로 찾습니다' : '불러오는 중…') + '</div>';
}
c.pop.innerHTML = html;
var activeEl = c.pop.querySelector('.mgm-opt.is-active');
if (activeEl && activeEl.scrollIntoView) activeEl.scrollIntoView({ block: 'nearest' });
}
function openComboFor(el) {
if (openCombo && openCombo.el === el) return;
closeCombo();
openCombo = {
field: el.getAttribute('data-combo'), el: el,
input: el.querySelector('input'), pop: el.querySelector('.mgm-pop'), active: -1, options: []
};
el.classList.add('is-open');
openCombo.input.setAttribute('aria-expanded', 'true');
openCombo.pop.hidden = false;
renderCombo(openCombo);
// 오른쪽 끝 칸에서 넓은 목록이 화면 밖으로 넘치면 오른쪽 기준으로 펼친다
var pop = openCombo.pop;
pop.classList.remove('is-right');
if (pop.getBoundingClientRect().right > document.documentElement.clientWidth - 8) pop.classList.add('is-right');
}
function closeCombo() {
if (!openCombo) return;
openCombo.el.classList.remove('is-open');
openCombo.input.setAttribute('aria-expanded', 'false');
openCombo.pop.hidden = true;
openCombo = null;
}
function chooseOption(c, index) {
var option = c.options[index];
if (!option) return;
c.input.value = option.key;
closeCombo();
applyFilters();
}
each($all('[data-combo]'), function (el) {
var input = el.querySelector('input');
input.addEventListener('focus', function () { openComboFor(el); });
input.addEventListener('click', function () { openComboFor(el); });
input.addEventListener('input', function () {
openComboFor(el);
openCombo.active = -1;
renderCombo(openCombo);
});
input.addEventListener('keydown', function (e) {
if (!openCombo || openCombo.el !== el) {
// 닫힌 상태에서 ↓ 를 누르면 열면서 첫 항목을 가리킨다
if (e.key === 'ArrowDown') { openComboFor(el); openCombo.active = 0; renderCombo(openCombo); e.preventDefault(); }
return;
}
var last = openCombo.options.length - 1;
if (e.key === 'ArrowDown') { openCombo.active = Math.min(last, openCombo.active + 1); renderCombo(openCombo); e.preventDefault(); }
else if (e.key === 'ArrowUp') { openCombo.active = Math.max(0, openCombo.active - 1); renderCombo(openCombo); e.preventDefault(); }
else if (e.key === 'Enter') {
e.preventDefault(); // 폼 제출 대신 선택
if (openCombo.active >= 0) chooseOption(openCombo, openCombo.active);
else { closeCombo(); applyFilters(); } // 목록에 없는 값도 입력한 그대로 찾는다
} else if (e.key === 'Escape') { closeCombo(); }
else if (e.key === 'Tab') { closeCombo(); }
});
el.querySelector('.mgm-combo-toggle').addEventListener('click', function () {
if (openCombo && openCombo.el === el) closeCombo();
else { input.focus(); openComboFor(el); }
});
// mousedown 에서 막아야 입력칸 포커스가 빠지기 전에 선택된다
el.querySelector('.mgm-pop').addEventListener('mousedown', function (e) {
e.preventDefault();
var opt = e.target.closest ? e.target.closest('.mgm-opt') : null;
if (opt) chooseOption(openCombo, Number(opt.getAttribute('data-index')));
});
});
// 바깥을 누르면 닫는다
document.addEventListener('mousedown', function (e) {
if (openCombo && !openCombo.el.contains(e.target)) closeCombo();
});
// 다른 화면에서 특정 값으로 회원 목록 열기
function openMembersWith(field, value, range) {
resetFilters();
var r = rangeDates(range);
filterForm.from.value = r.from; filterForm.to.value = r.to;
filterForm[field].value = value;
filterForm.inflowStatus.value = 'at_join';
setRangeChip(range);
notice(null);
go('members');
}
/* ===================== 신청 내역 ===================== */
var appForm = $('[data-role="app-filter"]');
function appFilters() {
var f = rangeQuery(state.app.range);
var out = {};
if (f.joinedFrom) out.appliedFrom = f.joinedFrom;
if (f.joinedTo) out.appliedTo = f.joinedTo;
var q = appForm.q.value.trim();
if (q) out.q = q;
if (state.app.product) out.productIdx = state.app.product;
return out;
}
function productLabel(p) {
return (p.productName || '상품') + ' #' + p.productIdx;
}
// 상품별 신청 수 칩 — 항상 펼쳐 둔다 (회원 그룹과 같은 방식)
function renderAppProducts() {
var box = $('[data-role="app-products"]');
var list = state.app.products || [];
var total = list.reduce(function (n, p) { return n + p.count; }, 0);
var chips = [{ productIdx: '', label: '전체', count: total }].concat(list.map(function (p) {
return { productIdx: p.productIdx, label: productLabel(p), count: p.count };
}));
box.innerHTML = list.length ? chips.map(function (c) {
var active = c.productIdx === state.app.product;
return '<button type="button" class="mgm-group-chip' + (active ? ' is-active' : '') +
'" data-app-product="' + esc(c.productIdx) + '" aria-pressed="' + active + '">' + esc(c.label) +
' <em>' + esc(num(c.count)) + '</em></button>';
}).join('') : '<span class="mgm-muted">아직 신청이 없습니다</span>';
}
function loadAppProducts() {
if (!state.app.productsStale) return;
state.app.productsStale = false;
api('GET', '/admin/applications/products').then(function (list) {
state.app.products = list;
renderAppProducts();
}).catch(function (err) { state.app.productsStale = true; errorNotice(err); });
}
function inflowText(inf) {
if (!inf || !inf.source) return '<span class="mgm-muted">-</span>';
return esc(inf.source) + (inf.medium ? ' / ' + esc(inf.medium) : '') +
(inf.campaign ? '<br><span class="mgm-muted">' + esc(inf.campaign) + '</span>' : '');
}
function appRowHtml(a) {
var m = a.member;
var idCell = m
? esc(m.uid) + (m.email && m.email !== m.uid ? '<br><span class="mgm-muted">' + esc(m.email) + '</span>' : '')
: esc(a.memberUid) + '<br><span class="mgm-muted">회원 정보 없음 (탈퇴 등)</span>';
return '<tr>' +
'<td>' + esc(kstDateTime(a.appliedAt)) + '</td>' +
'<td class="mgm-wrap">' + dash(a.productName) + ' <span class="mgm-muted">#' + esc(a.productIdx) + '</span></td>' +
'<td>' + dash(m && m.name) + '</td>' +
'<td>' + idCell + '</td>' +
'<td>' + dash(m && m.callnum) + '</td>' +
'<td>' + (a.kakaoClickedAt
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + '">눌렀음</span>'
: '<span class="mgm-muted">-</span>') + '</td>' +
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
'<td class="mgm-wrap">' + inflowText(m && m.inflow) + '</td>' +
'<td class="mgm-group-cell">' + (m && m.groups.length
? m.groups.map(function (g) { return '<span class="mgm-group-badge">' + esc(g.title) + '</span>'; }).join('')
: '<span class="mgm-muted">-</span>') + '</td>' +
'</tr>';
}
// 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다 (회원 목록과 같은 방식)
function loadApplications(reset) {
var app = state.app;
if (!reset && app.loading) return;
var seq = ++app.seq;
app.loading = true;
if (reset) { app.cursor = null; loadAppProducts(); }
var q = appFilters();
q.limit = PAGE_SIZE;
if (app.cursor) q.cursor = app.cursor;
var moreBtn = $('[data-action="app-more"]');
moreBtn.disabled = true;
api('GET', '/admin/applications' + toQuery(q)).then(function (page) {
if (seq !== app.seq) return;
var tbody = $('[data-role="app-rows"]');
var html = page.items.map(appRowHtml).join('');
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
app.cursor = page.pageInfo.nextCursor;
moreBtn.hidden = !page.pageInfo.hasNext;
$('[data-role="app-empty"]').hidden = tbody.children.length > 0;
var total = page.pageInfo.total;
$('[data-role="app-total"]').textContent = total === null ? '' :
'총 ' + num(total) + '건 중 ' + num(tbody.children.length) + '건 표시';
}).catch(function (err) {
if (seq === app.seq) errorNotice(err);
}).then(function () {
if (seq !== app.seq) return;
app.loading = false;
moreBtn.disabled = false;
});
}
each($all('[data-app-range]'), function (b) {
b.addEventListener('click', function () {
state.app.range = b.getAttribute('data-app-range');
each($all('[data-app-range]'), function (x) { x.classList.toggle('is-active', x === b); });
loadApplications(true);
});
});
$('[data-role="app-products"]').addEventListener('click', function (e) {
var chip = e.target.closest ? e.target.closest('[data-app-product]') : null;
if (!chip) return;
var code = chip.getAttribute('data-app-product');
state.app.product = state.app.product === code ? '' : code;
renderAppProducts();
loadApplications(true);
});
var appSearchTimer = null;
appForm.q.addEventListener('input', function () {
clearTimeout(appSearchTimer);
appSearchTimer = setTimeout(function () { loadApplications(true); }, SEARCH_DEBOUNCE_MS);
});
appForm.addEventListener('submit', function (e) {
e.preventDefault();
clearTimeout(appSearchTimer);
loadApplications(true);
});
/* ===================== 회원 목록 ===================== */
var STATUS_BADGE = {
at_join: '<span class="mgm-badge is-ok">가입 시</span>',
after_join: '<span class="mgm-badge is-warn" title="회원정보 수정 때 채워진 값 — 가입 경로가 아닙니다">가입 후</span>',
none: '<span class="mgm-badge">없음</span>'
};
var SOCIAL_LABEL = { kakao: '카카오', naver: '네이버', google: '구글', apple: '애플', facebook: '페이스북', line: '라인' };
function rowHtml(m) {
var inf = m.inflow;
var idCell = esc(m.uid) + (m.email && m.email !== m.uid ? '<br><span class="mgm-muted">' + esc(m.email) + '</span>' : '');
return '<tr>' +
'<td>' + esc(kstDateTime(m.joinTime)) + '</td>' +
'<td>' + dash(m.name) + '</td>' +
'<td>' + idCell + '</td>' +
'<td>' + dash(m.callnum) + '</td>' +
'<td>' + (m.socialProvider ? esc(SOCIAL_LABEL[m.socialProvider] || m.socialProvider) : '일반') + '</td>' +
'<td class="mgm-group-cell">' + (m.groups && m.groups.length
? m.groups.map(function (g) { return '<span class="mgm-group-badge">' + esc(g.title) + '</span>'; }).join('')
: '<span class="mgm-muted">-</span>') + '</td>' +
'<td>' + (STATUS_BADGE[inf.status] || '') +
(inf.origin === 'import' ? ' <span class="mgm-badge" title="아임웹 회원 엑셀에서 가져온 값">엑셀</span>' : '') + '</td>' +
'<td>' + dash(inf.source) + '</td>' +
'<td>' + dash(inf.medium) + '</td>' +
'<td class="mgm-wrap">' + dash(inf.campaign) + '</td>' +
'<td class="mgm-wrap">' + dash(inf.content) + '</td>' +
'<td class="mgm-wrap">' + dash(inf.term) + '</td>' +
'<td>' + dash(inf.landingPath) + (inf.productId ? ' <span class="mgm-muted">#' + esc(inf.productId) + '</span>' : '') + '</td>' +
'<td>' + dash(inf.firstTouchDate) + '</td>' +
'<td>' + dash(inf.lastTouch) + '</td>' +
'</tr>';
}
function loadMembers(reset) {
// 더 보기는 진행 중이면 무시하고, 필터 변경(reset)은 앞 요청을 버리고 새로 받는다
if (!reset && state.loading) return;
var seq = ++state.listSeq;
state.loading = true;
if (reset) {
state.cursor = null;
rememberApplied();
renderActiveFilters();
loadSuggestions();
loadGroups();
}
var q = currentFilters();
q.limit = PAGE_SIZE;
if (state.cursor) q.cursor = state.cursor;
var moreBtn = $('[data-action="more"]');
moreBtn.disabled = true;
api('GET', '/admin/members' + toQuery(q)).then(function (page) {
if (seq !== state.listSeq) return; // 더 늦게 보낸 요청이 있다
var tbody = $('[data-role="rows"]');
var html = page.items.map(rowHtml).join('');
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
state.cursor = page.pageInfo.nextCursor;
moreBtn.hidden = !page.pageInfo.hasNext;
$('[data-role="empty"]').hidden = tbody.children.length > 0;
var total = page.pageInfo.total;
$('[data-role="total"]').textContent = total === null ? '' :
'총 ' + num(total) + '명 중 ' + num(tbody.children.length) + '명 표시';
}).catch(function (err) {
if (seq === state.listSeq) errorNotice(err);
}).then(function () {
if (seq !== state.listSeq) return;
state.loading = false;
moreBtn.disabled = false;
});
}
/* ===================== 시작 ===================== */
// 아임웹 연결을 마치고 돌아온 경우 결과를 보여주고 주소창을 정리한다
(function handleOAuthReturn() {
var params = new URLSearchParams(location.search);
var result = params.get('imweb');
if (!result) return;
// 아임웹이 동의 단계에서 거부한 경우의 코드 — 개발자센터 설정으로 고칠 수 있는 것들
var IMWEB_HINT = {
'30156': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기(site-info:write)"를 켜 주세요.',
'30099': '개발자센터 앱 권한에 "회원 정보 읽기(member-info:read)"와 "사이트 정보 읽기∙쓰기"를 켜 주세요.',
'30098': '개발자센터 앱의 리다이렉트 URI 를 확인해 주세요.'
};
if (result === 'connected') notice('아임웹이 연결되었습니다. 첫 동기화를 시작합니다.', 'ok');
else {
var imwebCode = params.get('imwebCode');
var detail = imwebCode
? '아임웹 오류 ' + esc(imwebCode) + (IMWEB_HINT[imwebCode] ? ' — ' + esc(IMWEB_HINT[imwebCode]) : '')
: esc(params.get('code') || '알 수 없음');
notice('아임웹 연결에 실패했습니다. (' + detail + ')', 'error');
}
storageSet(VIEW_KEY, 'integration'); // 연결 결과는 연동 관리 화면에서 확인
params.delete('imweb'); params.delete('code'); params.delete('imwebCode');
var qs = params.toString();
history.replaceState(null, '', location.pathname + (qs ? '?' + qs : '') + location.hash);
})();
if (!loadSession()) showLogin();
else api('GET', '/admin/sessions/current').then(showApp, function (err) {
if (err && err.code === 'AUTH_001') { showLogin(); return; }
// 서버 연결 실패 등 — 세션은 남겨 두고(showLogin 은 세션을 지운다) 새로고침으로 다시 시도할 수 있게 한다
$('[data-role="login"]').hidden = false;
setNotice($('[data-role="login-notice"]'), errorText(err) + ' 잠시 후 새로고침해 주세요.', 'error');
});
})();
</script>
</div>