Files
moneyguide/imweb/imweb-members.html
ttipo 38e945767b
CI / Backend (lint · test · e2e · build) (push) Has been cancelled
CI / Frontend (lint · type-check · test) (push) Has been cancelled
CI / 에러 코드 SSOT 동기화 검사 (push) Has been cancelled
feat: 신청 때 최종 유입 저장과 신청 유입 집계 추가
가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점
브라우저의 '최초' 유입(2일 보관)이라, 그 사이 다른 경로로 다시 와서 신청하면
신청을 일으킨 경로가 가려졌다.

- member_applications 에 최종 유입(inflow_last_*) 저장 — 없으면 최초 유입으로 채움 (기존 행도 동일)
- 신청 완료 위젯이 MG_UTM.peek().last 를 lastInflow 로 함께 보냄
- GET /api/admin/applications/inflow-stats: 신청 때 최종·최초 / 가입 경로(가입 때 기록만) 기준 집계
- 신청 목록에 inflowBasis·inflowField·inflowValue 필터
- 관리자 화면 신청 내역: '신청 유입 집계' 카드(기준·항목 전환, 막대 클릭으로 목록 필터),
  신청 때 유입 칸에 최종 유입 표시 + 최초가 다르면 함께 표시

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 00:22:02 +09:00

2150 lines
113 KiB
HTML
Raw Permalink 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 tr.is-selected td { background: var(--mgm-accent-soft); }
/* 업체 링크 */
#mg-members .mgm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
#mg-members .mgm-url { display: inline-block; max-width: 280px; overflow: hidden; text-overflow: ellipsis; vertical-align: bottom; }
/* 연동 관리 */
#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="partners">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 21V9l9-6 9 6v12"/><path d="M9 21v-6h6v6"/><path d="M3 21h18"/></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" style="margin-bottom:16px">
<div class="mgm-card-head">
<h3>신청 유입 집계</h3>
<span class="mgm-muted" data-role="app-stats-desc">위 기간·상품 조건 · 막대를 누르면 아래 목록을 거릅니다</span>
<span class="mgm-spacer"></span>
<div class="mgm-seg" role="group" aria-label="유입 기준">
<button type="button" data-app-basis="apply_last" class="is-active" title="신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입">신청 때 (최종)</button>
<button type="button" data-app-basis="apply_first" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">신청 때 (최초)</button>
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
</div>
<div class="mgm-seg" role="group" aria-label="집계 항목">
<button type="button" data-app-group="source" class="is-active">유입원</button>
<button type="button" data-app-group="medium">매체</button>
<button type="button" data-app-group="campaign">캠페인</button>
<button type="button" data-app-group="content">콘텐츠</button>
</div>
</div>
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청</h3>
<span data-role="app-inflow-filter" class="mgm-row" style="gap:6px"></span>
<span class="mgm-spacer"></span>
<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 title="신청 직전 최종 유입 — 첫 경로와 다르면 아래에 '최초'로 함께 표시">신청 때 유입</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="partners" hidden>
<section class="mgm-card" style="margin-bottom:16px">
<div class="mgm-filter-top">
<div class="mgm-seg" role="group" aria-label="실적 집계 기간">
<button type="button" data-ptn-range="all" class="is-active">전체</button>
<button type="button" data-ptn-range="today">오늘</button>
<button type="button" data-ptn-range="7">7일</button>
<button type="button" data-ptn-range="30">30일</button>
<button type="button" data-ptn-range="month">이번 달</button>
</div>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="ptn-order-sync">구매 집계 상태 확인 중</span>
<button type="button" data-action="ptn-order-sync">구매 집계 새로고침</button>
<button type="button" data-action="ptn-product-sync">상품 불러오기</button>
</div>
<p class="mgm-muted" style="margin:10px 0 0">
링크를 누른 뒤 30일 안에 신청·구매하면, 그 상품의 링크 중 마지막으로 누른 업체 실적으로 잡습니다.
구매는 결제완료 주문만 세며(환불·취소 제외) 아임웹 주문을 1시간마다 확인합니다.
</p>
</section>
<div class="mgm-grid-2" style="margin-bottom:16px">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>업체</h3>
<span class="mgm-muted" data-role="ptn-partner-total"></span>
</div>
<form data-role="ptn-partner-form" class="mgm-row" style="margin-bottom:12px" autocomplete="off">
<input name="partnerName" maxlength="100" required placeholder="업체 이름" aria-label="업체 이름" style="flex:1 1 140px;width:auto">
<input name="memo" maxlength="1000" placeholder="메모 (선택 — 담당자·정산 조건 등)" aria-label="업체 메모" style="flex:2 1 200px;width:auto">
<button type="submit" class="mgm-primary">업체 추가</button>
</form>
<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="ptn-partner-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="ptn-partner-empty" hidden>등록된 업체가 없습니다. 위에서 업체를 추가해 주세요.</div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>링크 발급</h3>
<span class="mgm-muted">업체·상품 조합당 하나</span>
</div>
<form data-role="ptn-link-form" style="display:flex;flex-direction:column;gap:10px" autocomplete="off">
<label class="mgm-field">업체
<select name="partnerId" required><option value="">업체를 먼저 추가해 주세요</option></select>
</label>
<label class="mgm-field">상품
<select name="prodNo" required><option value="">'상품 불러오기'를 눌러 주세요</option></select>
</label>
<label class="mgm-field">이동할 상품 페이지 주소
<input name="targetUrl" type="url" required maxlength="2000" placeholder="https://www.moneyguider.co.kr/free-shop/?idx=52">
</label>
<span class="mgm-muted">아임웹에서 상품 페이지를 열고 주소창의 주소를 붙여 넣으세요. 우리 사이트 안의 주소만 됩니다.</span>
<div class="mgm-row"><button type="submit" class="mgm-primary">링크 발급</button></div>
</form>
</section>
</div>
<section class="mgm-card" style="margin-bottom:16px">
<div class="mgm-card-head">
<h3>링크</h3>
<span data-role="ptn-link-filter" class="mgm-row" style="gap:6px"></span>
<span class="mgm-spacer"></span>
<span class="mgm-muted" data-role="ptn-link-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="ptn-link-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="ptn-link-empty" hidden>발급한 링크가 없습니다.</div>
<div class="mgm-row" style="justify-content:center;margin-top:12px">
<button type="button" data-action="ptn-link-more" hidden>더 보기</button>
</div>
</section>
<section class="mgm-card" data-role="ptn-conv-card" hidden>
<div class="mgm-card-head">
<h3>실적 상세</h3>
<span class="mgm-muted" data-role="ptn-conv-title"></span>
<span class="mgm-spacer"></span>
<div class="mgm-seg" role="group" aria-label="실적 종류">
<button type="button" data-ptn-conv-type="" class="is-active">전체</button>
<button type="button" data-ptn-conv-type="application">신청</button>
<button type="button" data-ptn-conv-type="purchase">구매</button>
</div>
<button type="button" class="mgm-link" data-action="ptn-conv-close">닫기</button>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr><th>신청·주문 일시</th><th>종류</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th><th>주문번호</th><th>링크 클릭</th></tr>
</thead>
<tbody data-role="ptn-conv-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="ptn-conv-empty" hidden>아직 실적이 없습니다.</div>
<div class="mgm-row" style="justify-content:center;margin-top:12px">
<button type="button" data-action="ptn-conv-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: '누가 어떤 강의에 신청했는지 봅니다 (신청 완료 화면 기준)' },
partners: { 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,
// 신청 유입 집계 — basis: apply_last | apply_first | join, inflowFilter: 막대를 눌러 건 목록 필터
basis: 'apply_last', group: 'source', statsSeq: 0, inflowFilter: null
},
// 업체 링크 — partnerId 는 링크 목록 필터, conv 는 열어 둔 실적 상세
ptn: {
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
partners: [], products: null, syncTimer: null,
conv: { linkId: '', label: '', type: '', cursor: null, loading: false, seq: 0 }
},
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 === 'partners') loadPartnersView();
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;
var inf = state.app.inflowFilter;
if (inf) { out.inflowBasis = inf.basis; out.inflowField = inf.field; out.inflowValue = inf.value; }
return out;
}
/* 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 센다 */
var APP_BASIS_LABEL = { apply_last: '신청 때(최종)', apply_first: '신청 때(최초)', join: '가입 경로' };
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
var APP_STATS_TOP = 12;
function loadAppStats() {
var app = state.app;
var seq = ++app.statsSeq;
var q = { basis: app.basis, groupBy: app.group };
var f = appFilters(); // 기간·상품만 쓴다 (검색어·유입 필터는 목록 전용)
if (f.appliedFrom) q.appliedFrom = f.appliedFrom;
if (f.appliedTo) q.appliedTo = f.appliedTo;
if (f.productIdx) q.productIdx = f.productIdx;
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
if (seq !== app.statsSeq) return;
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, APP_STATS_TOP), true);
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' +
APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 아래 목록을 거릅니다';
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
}
function renderAppInflowFilter() {
var inf = state.app.inflowFilter;
$('[data-role="app-inflow-filter"]').innerHTML = inf
? '<span class="mgm-active-chip"><b>' + esc(APP_BASIS_LABEL[inf.basis] + ' ' + APP_GROUP_LABEL[inf.field]) + '</b> ' +
esc(inf.value) + '<button type="button" data-app-clear-inflow aria-label="유입 필터 지우기">×</button></span>'
: '';
}
// 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다
function applyInflowCell(a) {
var first = a.inflow, last = a.lastInflow && a.lastInflow.source ? a.lastInflow : first;
var html = inflowText(last);
if (first && first.source && last && last.source &&
(first.source !== last.source || first.medium !== last.medium || first.campaign !== last.campaign)) {
html += '<br><span class="mgm-muted" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">최초: ' +
esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + '</span>';
}
return html;
}
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">' + applyInflowCell(a) + '</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(); loadAppStats(); renderAppInflowFilter(); }
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);
});
// 집계 기준·항목 바꾸기 — 집계만 다시 센다 (목록 필터는 막대를 누를 때 바뀐다)
each($all('[data-app-basis]'), function (b) {
b.addEventListener('click', function () {
state.app.basis = b.getAttribute('data-app-basis');
each($all('[data-app-basis]'), function (x) { x.classList.toggle('is-active', x === b); });
loadAppStats();
});
});
each($all('[data-app-group]'), function (b) {
b.addEventListener('click', function () {
state.app.group = b.getAttribute('data-app-group');
each($all('[data-app-group]'), function (x) { x.classList.toggle('is-active', x === b); });
loadAppStats();
});
});
// 막대 → 같은 기준·항목·값으로 목록을 거른다 (다시 누르면 해제)
$('[data-role="app-stats-bars"]').addEventListener('click', function (e) {
var bar = e.target.closest ? e.target.closest('.mgm-bar') : null;
if (!bar || bar.disabled) return;
var value = bar.getAttribute('data-key');
var cur = state.app.inflowFilter;
var same = cur && cur.basis === state.app.basis && cur.field === state.app.group && cur.value === value;
state.app.inflowFilter = same ? null : { basis: state.app.basis, field: state.app.group, value: value };
loadApplications(true);
});
$('[data-role="app-inflow-filter"]').addEventListener('click', function (e) {
if (!e.target.closest || !e.target.closest('[data-app-clear-inflow]')) return;
state.app.inflowFilter = null;
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 ptnPanel = $('[data-panel="partners"]');
var ptnPartnerForm = $('[data-role="ptn-partner-form"]');
var ptnLinkForm = $('[data-role="ptn-link-form"]');
var PTN_LIST_LIMIT = 100; // 업체·상품 선택 목록은 한 번에 받는다 (API 최대값)
var PTN_PRODUCT_STATUS = { sale: '판매중', soldout: '품절', nosale: '숨김' };
var PTN_SYNC_HINT = {
IMWEB_006: '주문 읽기 권한이 없습니다 — 연동 관리에서 아임웹 연결을 다시 해 주세요',
IMWEB_001: '아임웹이 연결되어 있지 않습니다'
};
var PTN_CONV_TYPE = {
application: '<span class="mgm-badge is-ok">신청</span>',
purchase: '<span class="mgm-badge is-warn">구매</span>'
};
function ptnPeriod() {
var f = rangeQuery(state.ptn.range), out = {};
if (f.joinedFrom) out.from = f.joinedFrom;
if (f.joinedTo) out.to = f.joinedTo;
return out;
}
function statCells(s) {
return '<td>' + num(s.clickCount) + '</td><td>' + num(s.applicationCount) + '</td><td>' + num(s.purchaseCount) + '</td>';
}
function findPartner(id) {
return state.ptn.partners.filter(function (p) { return p.id === id; })[0] || null;
}
function loadPartnersView() {
loadPartners();
loadPtnProducts(false);
loadLinks(true);
loadOrderSyncStatus();
}
// 업체 목록 + 링크 발급 폼의 업체 선택
function loadPartners() {
return api('GET', '/admin/partners' + toQuery(Object.assign({ limit: PTN_LIST_LIMIT }, ptnPeriod())))
.then(function (page) {
state.ptn.partners = page.items;
renderPartners();
renderPartnerSelect();
renderLinkFilter();
}).catch(errorNotice);
}
function renderPartners() {
var list = state.ptn.partners;
$('[data-role="ptn-partner-rows"]').innerHTML = list.map(function (p) {
return '<tr' + (p.id === state.ptn.partnerId ? ' class="is-selected"' : '') + '>' +
'<td class="mgm-wrap"><button type="button" class="mgm-link" data-ptn-partner="' + esc(p.id) +
'" title="이 업체의 링크만 보기">' + esc(p.name) + '</button>' +
(p.memo ? '<br><span class="mgm-muted">' + esc(p.memo) + '</span>' : '') + '</td>' +
'<td>' + num(p.linkCount) + '</td>' + statCells(p.stats) +
'<td><button type="button" class="mgm-link" data-ptn-edit-partner="' + esc(p.id) + '">수정</button>' +
(p.linkCount === 0
? '<button type="button" class="mgm-link" data-ptn-delete-partner="' + esc(p.id) + '">삭제</button>'
: '') + '</td>' +
'</tr>';
}).join('');
$('[data-role="ptn-partner-empty"]').hidden = list.length > 0;
$('[data-role="ptn-partner-total"]').textContent = list.length ? num(list.length) + '곳' : '';
}
function renderPartnerSelect() {
var sel = ptnLinkForm.partnerId, current = sel.value || state.ptn.partnerId;
var list = state.ptn.partners;
sel.innerHTML = list.length
? '<option value="">업체 선택</option>' + list.map(function (p) {
return '<option value="' + esc(p.id) + '">' + esc(p.name) + '</option>';
}).join('')
: '<option value="">업체를 먼저 추가해 주세요</option>';
sel.value = current; // 없는 값이면 빈 값으로 돌아간다
}
function loadPtnProducts(force) {
if (state.ptn.products && !force) { renderProductSelect(); return Promise.resolve(); }
return api('GET', '/admin/products' + toQuery({ limit: PTN_LIST_LIMIT })).then(function (page) {
state.ptn.products = page.items;
renderProductSelect();
}).catch(errorNotice);
}
function renderProductSelect() {
var sel = ptnLinkForm.prodNo, current = sel.value;
var list = state.ptn.products || [];
sel.innerHTML = list.length
? '<option value="">상품 선택</option>' + list.map(function (p) {
var status = p.prodStatus !== 'sale' && PTN_PRODUCT_STATUS[p.prodStatus];
return '<option value="' + esc(p.prodNo) + '">' + esc(p.name) + ' #' + esc(p.prodNo) +
(status ? ' (' + esc(status) + ')' : '') + '</option>';
}).join('')
: '<option value="">\'상품 불러오기\'를 눌러 주세요</option>';
sel.value = current;
}
function renderLinkFilter() {
var p = findPartner(state.ptn.partnerId);
$('[data-role="ptn-link-filter"]').innerHTML = p
? '<span class="mgm-active-chip"><b>업체</b> ' + esc(p.name) +
'<button type="button" data-action="ptn-clear-partner" aria-label="업체 필터 지우기">×</button></span>'
: '';
}
function linkRowHtml(l) {
var label = l.partner.name + ' · ' + (l.product.name || '#' + l.product.prodNo);
return '<tr' + (l.id === state.ptn.conv.linkId ? ' class="is-selected"' : '') + ' data-link-row="' + esc(l.id) + '">' +
'<td>' + esc(l.partner.name) + '</td>' +
'<td class="mgm-wrap">' + (l.product.name ? esc(l.product.name) : '<span class="mgm-muted">아임웹에서 지운 상품</span>') +
' <span class="mgm-muted">#' + esc(l.product.prodNo) + '</span></td>' +
'<td><span class="mgm-code">' + esc(l.shortUrl) + '</span>' +
'<button type="button" class="mgm-link" data-ptn-copy="' + esc(l.shortUrl) + '">복사</button>' +
'<br><span class="mgm-muted mgm-url" title="' + esc(l.targetUrl) + '">→ ' + esc(l.targetUrl) + '</span></td>' +
statCells(l.stats) +
'<td>' + (l.isActive ? '<span class="mgm-badge is-ok">사용</span>' : '<span class="mgm-badge">중지</span>') + '</td>' +
'<td>' + esc(kstDateTime(l.createdAt).slice(0, 10)) + '</td>' +
'<td>' +
'<button type="button" class="mgm-link" data-ptn-conv="' + esc(l.id) + '" data-label="' + esc(label) + '">실적</button>' +
'<button type="button" class="mgm-link" data-ptn-edit-link="' + esc(l.id) + '" data-url="' + esc(l.targetUrl) + '">주소 변경</button>' +
'<button type="button" class="mgm-link" data-ptn-toggle-link="' + esc(l.id) + '" data-active="' + (l.isActive ? '1' : '') + '">' +
(l.isActive ? '중지' : '다시 사용') + '</button>' +
'</td>' +
'</tr>';
}
// 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다 (회원 목록과 같은 방식)
function loadLinks(reset) {
var ptn = state.ptn;
if (!reset && ptn.loading) return;
var seq = ++ptn.seq;
ptn.loading = true;
if (reset) ptn.cursor = null;
var q = Object.assign({ limit: PAGE_SIZE }, ptnPeriod());
if (ptn.partnerId) q.partnerId = ptn.partnerId;
if (ptn.cursor) q.cursor = ptn.cursor;
var moreBtn = $('[data-action="ptn-link-more"]');
moreBtn.disabled = true;
api('GET', '/admin/partner-links' + toQuery(q)).then(function (page) {
if (seq !== ptn.seq) return;
var tbody = $('[data-role="ptn-link-rows"]');
var html = page.items.map(linkRowHtml).join('');
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
ptn.cursor = page.pageInfo.nextCursor;
moreBtn.hidden = !page.pageInfo.hasNext;
$('[data-role="ptn-link-empty"]').hidden = tbody.children.length > 0;
var total = page.pageInfo.total;
$('[data-role="ptn-link-total"]').textContent = total === null ? '' : '총 ' + num(total) + '개';
}).catch(function (err) {
if (seq === ptn.seq) errorNotice(err);
}).then(function () {
if (seq !== ptn.seq) return;
ptn.loading = false;
moreBtn.disabled = false;
});
}
function convRowHtml(c) {
var m = c.member;
var idCell = m
? esc(m.uid) + (m.email && m.email !== m.uid ? '<br><span class="mgm-muted">' + esc(m.email) + '</span>' : '')
: esc(c.memberCode) + '<br><span class="mgm-muted">회원 정보 없음 (탈퇴 등)</span>';
return '<tr>' +
'<td>' + esc(kstDateTime(c.convertedAt)) + '</td>' +
'<td>' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
(c.isValid ? '' : ' <span class="mgm-badge is-danger" title="환불·취소되어 집계에서 빠졌습니다">환불·취소</span>') + '</td>' +
'<td>' + dash(m && m.name) + '</td>' +
'<td>' + idCell + '</td>' +
'<td>' + dash(m && m.callnum) + '</td>' +
'<td>' + dash(c.orderNo) + '</td>' +
'<td>' + esc(kstDateTime(c.touchedAt)) + '</td>' +
'</tr>';
}
function markSelectedLink() {
each($all('[data-link-row]'), function (tr) {
tr.classList.toggle('is-selected', tr.getAttribute('data-link-row') === state.ptn.conv.linkId);
});
}
function openConversions(linkId, label) {
var conv = state.ptn.conv;
conv.linkId = linkId;
conv.type = '';
each($all('[data-ptn-conv-type]'), function (x) {
x.classList.toggle('is-active', x.getAttribute('data-ptn-conv-type') === '');
});
$('[data-role="ptn-conv-title"]').textContent = label + ' · 전체 기간';
var card = $('[data-role="ptn-conv-card"]');
card.hidden = false;
markSelectedLink();
loadConversions(true);
if (card.scrollIntoView) card.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
function loadConversions(reset) {
var conv = state.ptn.conv;
if (!conv.linkId || (!reset && conv.loading)) return;
var seq = ++conv.seq;
conv.loading = true;
if (reset) conv.cursor = null;
var q = { limit: PAGE_SIZE };
if (conv.type) q.type = conv.type;
if (conv.cursor) q.cursor = conv.cursor;
var moreBtn = $('[data-action="ptn-conv-more"]');
moreBtn.disabled = true;
api('GET', '/admin/partner-links/' + encodeURIComponent(conv.linkId) + '/conversions' + toQuery(q)).then(function (page) {
if (seq !== conv.seq) return;
var tbody = $('[data-role="ptn-conv-rows"]');
var html = page.items.map(convRowHtml).join('');
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
conv.cursor = page.pageInfo.nextCursor;
moreBtn.hidden = !page.pageInfo.hasNext;
$('[data-role="ptn-conv-empty"]').hidden = tbody.children.length > 0;
}).catch(function (err) {
if (seq === conv.seq) errorNotice(err);
}).then(function () {
if (seq !== conv.seq) return;
conv.loading = false;
moreBtn.disabled = false;
});
}
function renderOrderSync(s) {
var el = $('[data-role="ptn-order-sync"]');
if (s.isRunning) { el.textContent = '구매 집계 중…'; return; }
if (s.lastErrorCode) {
el.innerHTML = '<span class="mgm-badge is-danger">' + esc(s.lastErrorCode) + '</span> ' +
esc(PTN_SYNC_HINT[s.lastErrorCode] || '구매 집계에 실패했습니다');
return;
}
el.textContent = s.lastFinishedAt
? '구매 집계 ' + kstDateTime(s.lastFinishedAt) + ' · 주문 ' + num(s.checkedOrderCount) + '건 확인'
: '구매 집계 대기 중 (1시간마다 자동)';
}
function loadOrderSyncStatus() {
return api('GET', '/admin/partner-order-sync-runs/latest').then(function (s) {
renderOrderSync(s);
return s;
}).catch(function (err) { errorNotice(err); return null; });
}
// 새로고침이 끝날 때까지 상태를 확인하고, 끝나면 숫자를 다시 불러온다
function pollOrderSync() {
clearTimeout(state.ptn.syncTimer);
state.ptn.syncTimer = setTimeout(function () {
loadOrderSyncStatus().then(function (s) {
if (s && s.isRunning) { pollOrderSync(); return; }
$('[data-action="ptn-order-sync"]').disabled = false;
if (s && !s.lastErrorCode) notice('구매 집계를 마쳤습니다. 새로 잡힌 구매 ' + num(s.createdCount) + '건', 'ok');
if (state.view === 'partners') {
loadPartners();
loadLinks(true);
loadConversions(true);
}
});
}, SYNC_POLL_MS);
}
function copyText(text) {
if (!navigator.clipboard || !navigator.clipboard.writeText) return Promise.resolve(false);
return navigator.clipboard.writeText(text).then(function () { return true; }, function () { return false; });
}
each($all('[data-ptn-range]'), function (b) {
b.addEventListener('click', function () {
state.ptn.range = b.getAttribute('data-ptn-range');
each($all('[data-ptn-range]'), function (x) { x.classList.toggle('is-active', x === b); });
loadPartners();
loadLinks(true);
});
});
ptnPartnerForm.addEventListener('submit', function (e) {
e.preventDefault();
// form.name 은 폼 자신의 name 속성이라 입력란 이름을 partnerName 으로 둔다
var name = ptnPartnerForm.partnerName.value.trim();
if (!name) return;
var body = { name: name };
var memo = ptnPartnerForm.memo.value.trim();
if (memo) body.memo = memo;
api('POST', '/admin/partners', body).then(function (p) {
ptnPartnerForm.reset();
notice('업체 "' + esc(p.name) + '"을(를) 추가했습니다. 오른쪽에서 링크를 발급하세요.', 'ok');
ptnLinkForm.partnerId.value = '';
return loadPartners().then(function () { ptnLinkForm.partnerId.value = p.id; });
}).catch(errorNotice);
});
ptnLinkForm.addEventListener('submit', function (e) {
e.preventDefault();
var body = {
partnerId: ptnLinkForm.partnerId.value,
prodNo: Number(ptnLinkForm.prodNo.value),
targetUrl: ptnLinkForm.targetUrl.value.trim()
};
if (!body.partnerId || !body.prodNo || !body.targetUrl) return;
api('POST', '/admin/partner-links', body).then(function (l) {
ptnLinkForm.targetUrl.value = '';
notice('링크를 발급했습니다. 업체에 이 주소를 전달하세요: <b>' + esc(l.shortUrl) + '</b>', 'ok');
loadPartners();
loadLinks(true);
}).catch(errorNotice);
});
ptnPanel.addEventListener('click', function (e) {
var t = e.target.closest ? e.target.closest('button') : null;
if (!t || !ptnPanel.contains(t)) return;
var action = t.getAttribute('data-action');
var id;
if (action === 'ptn-product-sync') {
t.disabled = true;
api('POST', '/admin/products/sync-runs').then(function (r) {
notice('아임웹 상품 ' + num(r.fetchedCount) + '개를 불러왔습니다.', 'ok');
return loadPtnProducts(true);
}).catch(errorNotice).then(function () { t.disabled = false; });
} else if (action === 'ptn-order-sync') {
t.disabled = true;
api('POST', '/admin/partner-order-sync-runs').then(function (s) {
renderOrderSync(s);
pollOrderSync();
}).catch(function (err) { t.disabled = false; errorNotice(err); });
} else if (action === 'ptn-link-more') {
loadLinks(false);
} else if (action === 'ptn-conv-more') {
loadConversions(false);
} else if (action === 'ptn-conv-close') {
state.ptn.conv.linkId = '';
$('[data-role="ptn-conv-card"]').hidden = true;
markSelectedLink();
} else if (action === 'ptn-clear-partner') {
state.ptn.partnerId = '';
renderPartners();
renderLinkFilter();
loadLinks(true);
} else if ((id = t.getAttribute('data-ptn-partner'))) {
// 같은 업체를 다시 누르면 필터를 푼다
state.ptn.partnerId = state.ptn.partnerId === id ? '' : id;
if (state.ptn.partnerId) ptnLinkForm.partnerId.value = id;
renderPartners();
renderLinkFilter();
loadLinks(true);
} else if ((id = t.getAttribute('data-ptn-edit-partner'))) {
var partner = findPartner(id);
if (!partner) return;
var newName = window.prompt('업체 이름', partner.name);
if (newName === null || !newName.trim()) return;
var newMemo = window.prompt('메모 (비우면 지웁니다)', partner.memo || '');
if (newMemo === null) return;
api('PATCH', '/admin/partners/' + encodeURIComponent(id), { name: newName.trim(), memo: newMemo.trim() })
.then(function () { loadPartners(); loadLinks(true); })
.catch(errorNotice);
} else if ((id = t.getAttribute('data-ptn-delete-partner'))) {
var target = findPartner(id);
if (!target || !window.confirm('업체 "' + target.name + '"을(를) 삭제할까요?')) return;
api('DELETE', '/admin/partners/' + encodeURIComponent(id)).then(function () {
if (state.ptn.partnerId === id) state.ptn.partnerId = '';
loadPartners();
}).catch(errorNotice);
} else if (t.hasAttribute('data-ptn-copy')) {
var url = t.getAttribute('data-ptn-copy');
copyText(url).then(function (ok) {
notice(ok ? '링크를 복사했습니다: <b>' + esc(url) + '</b>'
: '복사하지 못했습니다. 직접 선택해 복사해 주세요: <b>' + esc(url) + '</b>', ok ? 'ok' : 'error');
});
} else if ((id = t.getAttribute('data-ptn-conv'))) {
openConversions(id, t.getAttribute('data-label') || '');
} else if ((id = t.getAttribute('data-ptn-edit-link'))) {
var nextUrl = window.prompt('이동할 상품 페이지 주소', t.getAttribute('data-url') || '');
if (nextUrl === null || !nextUrl.trim()) return;
api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { targetUrl: nextUrl.trim() })
.then(function () { notice('이동 주소를 바꿨습니다. 업체에 준 링크는 그대로 쓰면 됩니다.', 'ok'); loadLinks(true); })
.catch(errorNotice);
} else if ((id = t.getAttribute('data-ptn-toggle-link'))) {
var isActive = t.getAttribute('data-active') === '1';
if (isActive && !window.confirm('이 링크를 중지할까요?\n중지해도 상품 페이지로 이동은 되지만, 클릭·실적은 더 이상 쌓이지 않습니다.')) return;
api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { isActive: !isActive })
.then(function () { loadLinks(true); })
.catch(errorNotice);
} else if (t.hasAttribute('data-ptn-conv-type')) {
state.ptn.conv.type = t.getAttribute('data-ptn-conv-type');
each($all('[data-ptn-conv-type]'), function (x) { x.classList.toggle('is-active', x === t); });
loadConversions(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': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기", "회원 정보 읽기", "상품 읽기", "주문 읽기"를 모두 켜 주세요.',
'30099': '개발자센터 앱 권한에 "사이트 정보 읽기∙쓰기", "회원 정보 읽기", "상품 읽기", "주문 읽기"를 모두 켜 주세요.',
'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>