Files
moneyguide/imweb/imweb-members.html
T
ttipo 1052cc3348 refactor: 신청 화면에서 신청 직전(최종) 유입 표시 제거
운영에 필요한 것은 업체 링크로 들어와 누가 신청·구매했는지이고, 신청 직전 유입은
쓰지 않는다. 기준이 둘(직전·최초)이라 화면만 복잡해져 신청 때 유입은 한 가지만 보여준다.

- 신청 유입 집계 기준: 신청 직전 · 신청 때 최초 · 가입 경로 → 신청 때 유입 · 가입 경로
- 신청자 표·상세 패널: 신청 때 유입을 한 줄로 (최초/직전 구분 제거)
- 개요의 신청 유입 상위도 신청 때 유입 기준
- 회원 상세의 가입 경로에서 '최종 유입' 줄 제거
백엔드는 그대로 둔다 (최종 유입은 저장만 하고 화면에 쓰지 않는다)

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

2642 lines
147 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: #f3f5f8;
--mgm-surface: #ffffff;
--mgm-border: #e3e7ec;
--mgm-border-strong: #cfd5dd;
--mgm-text: #1b1f27;
--mgm-muted: #6b7380;
--mgm-faint: #9aa1ad;
--mgm-side: #141922;
--mgm-side-text: #a9b1bf;
--mgm-accent: #2f6fed;
--mgm-accent-soft: #eaf1ff;
--mgm-warn: #b45309;
--mgm-warn-soft: #fff4e5;
--mgm-danger: #c62828;
--mgm-danger-soft: #fdecec;
--mgm-ok: #1b7f4b;
--mgm-ok-soft: #e7f6ee;
--mgm-shadow: 0 1px 2px rgba(16, 24, 40, .04), 0 1px 3px rgba(16, 24, 40, .06);
--mgm-shadow-lg: 0 12px 32px rgba(16, 24, 40, .18);
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, #mg-members h4 { margin: 0; font-weight: 700; letter-spacing: -.01em; }
#mg-members h1 { font-size: 22px; }
#mg-members h3 { font-size: 15px; }
#mg-members h4 { font-size: 13px; color: var(--mgm-muted); font-weight: 600; }
#mg-members p { margin: 0; }
#mg-members .mgm-muted { color: var(--mgm-muted); font-size: 13px; }
#mg-members .mgm-faint { color: var(--mgm-faint); }
#mg-members .mgm-row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
#mg-members .mgm-spacer { flex: 1 1 auto; }
#mg-members .mgm-num { font-variant-numeric: tabular-nums; }
/* ---------- 공통 컨트롤 ---------- */
#mg-members button {
font: inherit; font-size: 13px; cursor: pointer; line-height: 1.4;
border: 1px solid var(--mgm-border); background: var(--mgm-surface); color: var(--mgm-text);
padding: 7px 12px; border-radius: 8px; white-space: nowrap;
transition: background-color .12s, border-color .12s, color .12s;
}
#mg-members button:hover { border-color: var(--mgm-border-strong); background: #fafbfc; }
#mg-members button:focus-visible { outline: 2px solid var(--mgm-accent); outline-offset: 2px; }
#mg-members button:disabled { opacity: .5; cursor: default; }
#mg-members button.mgm-primary { background: var(--mgm-accent); border-color: var(--mgm-accent); color: #fff; font-weight: 600; }
#mg-members button.mgm-primary:hover { background: #255ed1; }
#mg-members button.mgm-danger { background: var(--mgm-danger); border-color: var(--mgm-danger); color: #fff; font-weight: 600; }
#mg-members button.mgm-link { border: 0; background: none; color: var(--mgm-accent); padding: 4px 6px; }
#mg-members button.mgm-link:hover { text-decoration: underline; background: none; }
#mg-members button.mgm-link.is-danger { color: var(--mgm-danger); }
#mg-members button.mgm-sm { padding: 4px 9px; font-size: 12px; }
#mg-members input, #mg-members select, #mg-members textarea {
font: inherit; font-size: 13px; padding: 7px 10px; border: 1px solid var(--mgm-border);
border-radius: 8px; background: #fff; color: var(--mgm-text); min-width: 0; width: 100%;
}
#mg-members input, #mg-members select { height: 36px; }
#mg-members input:focus, #mg-members select:focus, #mg-members textarea:focus { outline: none; border-color: var(--mgm-accent); box-shadow: 0 0 0 3px var(--mgm-accent-soft); }
#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-field { display: flex; flex-direction: column; gap: 5px; font-size: 12px; color: var(--mgm-muted); font-weight: 600; min-width: 0; }
#mg-members .mgm-hint { font-size: 12px; color: var(--mgm-muted); font-weight: 400; }
#mg-members .mgm-badge {
display: inline-block; font-size: 12px; padding: 1px 8px; border-radius: 999px; white-space: nowrap;
background: var(--mgm-bg); color: var(--mgm-muted); border: 1px solid var(--mgm-border); font-weight: 500;
}
#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-badge.is-accent { background: var(--mgm-accent-soft); color: var(--mgm-accent); border-color: transparent; }
#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; box-shadow: var(--mgm-shadow); min-width: 0;
}
#mg-members .mgm-card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 14px; flex-wrap: wrap; min-height: 28px; }
#mg-members .mgm-card-head .mgm-muted { font-size: 12px; }
#mg-members .mgm-stack { display: flex; flex-direction: column; gap: 16px; }
#mg-members .mgm-toolbar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; }
/* 세그먼트 버튼 */
#mg-members .mgm-seg { display: inline-flex; border: 1px solid var(--mgm-border); border-radius: 9px; overflow: hidden; background: var(--mgm-surface); flex: none; }
#mg-members .mgm-seg button { border: 0; border-radius: 0; padding: 7px 12px; background: none; color: var(--mgm-muted); }
#mg-members .mgm-seg button + button { border-left: 1px solid var(--mgm-border); }
#mg-members .mgm-seg button:hover { background: #f7f9fc; color: var(--mgm-text); }
#mg-members .mgm-seg button.is-active { background: var(--mgm-accent-soft); color: var(--mgm-accent); font-weight: 600; }
/* 큰 전환 버튼 (상품별 / 업체별) */
#mg-members .mgm-seg.is-large button { padding: 8px 16px; font-size: 14px; }
/* 검색 */
#mg-members .mgm-search { position: relative; flex: 1 1 260px; min-width: 200px; }
#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-left: 34px; }
#mg-members .mgm-dates { display: inline-flex; align-items: center; gap: 6px; }
#mg-members .mgm-dates input { width: 140px; }
/* 필터 칩 */
#mg-members .mgm-chiprow { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
#mg-members .mgm-chiprow-label { font-size: 12px; color: var(--mgm-muted); margin-right: 4px; font-weight: 600; }
#mg-members .mgm-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border-radius: 999px; font-size: 13px; }
#mg-members .mgm-chip em { font-style: normal; font-size: 12px; color: var(--mgm-muted); font-variant-numeric: tabular-nums; }
#mg-members .mgm-chip.is-active { background: var(--mgm-accent); border-color: var(--mgm-accent); color: #fff; }
#mg-members .mgm-chip.is-active em { color: rgba(255,255,255,.85); }
#mg-members .mgm-chip.is-empty { opacity: .45; }
#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; }
#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-login-wrap { background: var(--mgm-bg); border-radius: 14px; padding: 64px 16px; }
#mg-members .mgm-login { max-width: 380px; margin: 0 auto; padding: 28px; }
#mg-members .mgm-login form { display: flex; flex-direction: column; gap: 12px; margin-top: 16px; }
#mg-members .mgm-login .mgm-primary { height: 40px; font-size: 14px; }
#mg-members .mgm-login-notice { padding: 10px 12px; border-radius: 8px; margin-top: 12px; font-size: 13px; background: var(--mgm-danger-soft); color: var(--mgm-danger); }
/* ---------- 셸: 왼쪽 메뉴 + 오른쪽 본문 ---------- */
#mg-members .mgm-shell {
--mgm-side-width: 220px;
display: grid; grid-template-columns: var(--mgm-side-width) minmax(0, 1fr);
min-height: 780px; 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 12px; gap: 16px;
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 { display: flex; align-items: center; gap: 10px; color: #fff; padding: 0 8px; }
#mg-members .mgm-logo { width: 32px; height: 32px; border-radius: 9px; background: var(--mgm-accent); display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 15px; flex: none; }
#mg-members .mgm-brand strong { display: block; font-size: 14px; }
#mg-members .mgm-brand span { font-size: 12px; color: var(--mgm-side-text); }
#mg-members .mgm-account {
display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 0 4px;
padding: 8px 10px; border-radius: 9px; background: rgba(255,255,255,.05); font-size: 12px; white-space: nowrap;
}
#mg-members .mgm-account span { overflow: hidden; text-overflow: ellipsis; }
#mg-members .mgm-account button { flex: none; background: none; border-color: rgba(255,255,255,.18); color: var(--mgm-side-text); padding: 3px 9px; font-size: 12px; }
#mg-members .mgm-account button:hover { color: #fff; border-color: rgba(255,255,255,.4); background: none; }
#mg-members .mgm-nav { display: flex; flex-direction: column; gap: 2px; }
#mg-members .mgm-nav-label { font-size: 11px; font-weight: 700; letter-spacing: .04em; color: #6c7584; padding: 12px 10px 4px; }
#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: #fff; font-weight: 600; }
#mg-members .mgm-nav svg { width: 18px; height: 18px; flex: none; }
#mg-members .mgm-main { padding: 24px 26px 32px; min-width: 0; }
#mg-members .mgm-topbar { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; }
#mg-members .mgm-topbar p { margin-top: 4px; }
#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: 5px 12px; }
#mg-members .mgm-sync-pill::before { content: ''; display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: var(--mgm-faint); margin-right: 6px; vertical-align: 1px; }
#mg-members .mgm-sync-pill.is-ok::before { background: var(--mgm-ok); }
#mg-members .mgm-sync-pill.is-warn::before { background: var(--mgm-warn); }
#mg-members .mgm-sync-pill.is-busy::before { background: var(--mgm-accent); }
/* ---------- 지표 카드 ---------- */
#mg-members .mgm-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
#mg-members .mgm-kpi { display: block; width: 100%; text-align: left; padding: 16px 18px; border-radius: 12px; white-space: normal; }
#mg-members button.mgm-kpi:hover { border-color: var(--mgm-accent); background: var(--mgm-surface); }
#mg-members .mgm-kpi-label { display: block; font-size: 12px; color: var(--mgm-muted); font-weight: 600; }
#mg-members .mgm-kpi-value { display: block; font-size: 28px; font-weight: 700; margin-top: 4px; font-variant-numeric: tabular-nums; letter-spacing: -.02em; }
#mg-members .mgm-kpi-sub { display: block; font-size: 12px; color: var(--mgm-muted); margin-top: 2px; }
#mg-members .mgm-kpis.is-compact .mgm-kpi { padding: 12px 16px; }
#mg-members .mgm-kpis.is-compact .mgm-kpi-value { font-size: 22px; }
#mg-members .mgm-grid-2 { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 16px; }
#mg-members .mgm-grid-2.is-wide-left { grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); }
#mg-members .mgm-split { display: grid; grid-template-columns: 300px minmax(0, 1fr); gap: 16px; align-items: start; }
/* ---------- 막대 ---------- */
#mg-members .mgm-bars { display: flex; flex-direction: column; gap: 4px; }
#mg-members .mgm-bar {
display: grid; grid-template-columns: minmax(80px, 180px) 1fr 56px; gap: 10px; align-items: center;
border: 0; background: none; padding: 5px 6px; text-align: left; border-radius: 6px; width: 100%;
}
#mg-members .mgm-bar:hover:not(:disabled) { background: var(--mgm-bg); }
#mg-members .mgm-bar:disabled { opacity: 1; }
#mg-members .mgm-bar.is-selected { background: var(--mgm-accent-soft); }
#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: 8px; background: var(--mgm-bg); border-radius: 4px; overflow: hidden; }
#mg-members .mgm-bar-fill { height: 100%; background: var(--mgm-accent); border-radius: 4px; }
#mg-members .mgm-bar-count { text-align: right; font-variant-numeric: tabular-nums; font-weight: 600; }
/* ---------- 표 ---------- */
#mg-members .mgm-table-wrap { overflow-x: auto; margin: 0 -18px; padding: 0 18px; }
#mg-members table { width: 100%; border-collapse: collapse; font-size: 13px; }
#mg-members th, #mg-members td { padding: 10px 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; z-index: 1; }
#mg-members tbody tr:last-child td { border-bottom: 0; }
#mg-members th.mgm-r, #mg-members td.mgm-r { text-align: right; font-variant-numeric: tabular-nums; }
#mg-members td.mgm-wrap { white-space: normal; min-width: 140px; }
#mg-members tr.is-clickable { cursor: pointer; }
#mg-members tr.is-clickable:hover td { background: #f7f9fc; }
#mg-members tr.is-selected td { background: var(--mgm-accent-soft); }
#mg-members .mgm-cell-main { display: block; font-weight: 600; }
#mg-members .mgm-cell-sub { display: block; font-size: 12px; color: var(--mgm-muted); margin-top: 1px; }
#mg-members .mgm-empty { padding: 36px 16px; text-align: center; color: var(--mgm-muted); }
#mg-members .mgm-empty b { display: block; color: var(--mgm-text); font-size: 14px; margin-bottom: 4px; }
#mg-members .mgm-empty button { margin-top: 12px; }
#mg-members .mgm-more { display: flex; justify-content: center; margin-top: 12px; }
#mg-members .mgm-rate { font-size: 12px; color: var(--mgm-muted); margin-left: 4px; }
/* ---------- 업체 목록 (왼쪽) ---------- */
#mg-members .mgm-plist { display: flex; flex-direction: column; gap: 6px; }
#mg-members .mgm-pitem { display: block; width: 100%; text-align: left; padding: 10px 12px; border-radius: 10px; white-space: normal; }
#mg-members .mgm-pitem.is-active { background: var(--mgm-accent-soft); border-color: var(--mgm-accent); }
#mg-members .mgm-pitem-name { display: flex; align-items: center; gap: 6px; font-weight: 600; font-size: 14px; }
#mg-members .mgm-pitem-memo { display: block; font-size: 12px; color: var(--mgm-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#mg-members .mgm-pitem-stats { display: flex; gap: 10px; margin-top: 6px; font-size: 12px; color: var(--mgm-muted); font-variant-numeric: tabular-nums; }
#mg-members .mgm-pitem-stats b { color: var(--mgm-text); font-weight: 600; }
#mg-members .mgm-pitem-actions { display: flex; gap: 2px; margin: 2px 0 2px 4px; }
/* 업체 링크 칸 */
#mg-members .mgm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
/* 업체에 줄 링크 칸 — 1줄: 링크 + 복사(가운데 정렬), 2줄: 이동 주소(길면 말줄임) */
#mg-members .mgm-linkcell { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
#mg-members .mgm-linkcell-top { display: flex; align-items: center; gap: 6px; }
#mg-members .mgm-linkcell-top .mgm-link { padding: 0 4px; line-height: 18px; }
#mg-members .mgm-url { display: block; max-width: 340px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--mgm-muted); }
#mg-members .mgm-linkbox { display: flex; gap: 6px; }
#mg-members .mgm-linkbox input { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; background: var(--mgm-bg); }
/* 사용/중지 스위치 */
#mg-members button.mgm-switch {
position: relative; width: 38px; height: 22px; padding: 0; border-radius: 999px; border: 0;
background: var(--mgm-border-strong); flex: none;
}
#mg-members button.mgm-switch::after {
content: ''; position: absolute; top: 3px; left: 3px; width: 16px; height: 16px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 2px rgba(0,0,0,.2); transition: transform .15s;
}
#mg-members button.mgm-switch[aria-pressed="true"] { background: var(--mgm-ok); }
#mg-members button.mgm-switch[aria-pressed="true"]::after { transform: translateX(16px); }
#mg-members .mgm-switch-wrap { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--mgm-muted); }
/* 도움말 */
#mg-members details.mgm-help { background: var(--mgm-surface); border: 1px solid var(--mgm-border); border-radius: 12px; padding: 0 16px; }
#mg-members details.mgm-help summary { cursor: pointer; padding: 12px 0; font-size: 13px; font-weight: 600; color: var(--mgm-muted); list-style: none; }
#mg-members details.mgm-help summary::-webkit-details-marker { display: none; }
#mg-members details.mgm-help summary::before { content: '▸'; display: inline-block; margin-right: 6px; transition: transform .15s; }
#mg-members details.mgm-help[open] summary::before { transform: rotate(90deg); }
#mg-members details.mgm-help ul { margin: 0 0 14px; padding-left: 18px; color: var(--mgm-muted); font-size: 13px; }
#mg-members details.mgm-help li + li { margin-top: 4px; }
/* 정의 목록 */
#mg-members .mgm-dl { display: grid; grid-template-columns: 120px 1fr; gap: 8px 14px; margin: 0; font-size: 13px; }
#mg-members .mgm-dl dt { color: var(--mgm-muted); }
#mg-members .mgm-dl dd { margin: 0; word-break: break-all; }
/* ---------- 드롭다운(직접 그림) — 브라우저 기본 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:hover { background: none; }
#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-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-filter-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
#mg-members .mgm-divider { border-top: 1px dashed var(--mgm-border); margin: 14px 0 0; padding-top: 14px; }
/* ---------- 알림(토스트) ---------- */
#mg-members .mgm-toasts { position: fixed; top: 16px; right: 16px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; width: min(400px, calc(100vw - 32px)); pointer-events: none; }
#mg-members .mgm-toast {
pointer-events: auto; display: flex; gap: 10px; align-items: flex-start; padding: 12px 12px 12px 14px;
background: #fff; border: 1px solid var(--mgm-border); border-left: 4px solid var(--mgm-ok); border-radius: 10px;
box-shadow: var(--mgm-shadow-lg); font-size: 13px; animation: mgm-in .18s ease-out;
}
#mg-members .mgm-toast.is-error { border-left-color: var(--mgm-danger); }
#mg-members .mgm-toast div { flex: 1; word-break: break-all; }
#mg-members .mgm-toast button { border: 0; background: none; padding: 0 4px; font-size: 16px; line-height: 1; color: var(--mgm-muted); }
@keyframes mgm-in { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
/* ---------- 상세 패널(오른쪽) ---------- */
#mg-members .mgm-drawer { position: fixed; inset: 0; z-index: 9990; }
#mg-members .mgm-drawer-backdrop { position: absolute; inset: 0; background: rgba(15, 20, 30, .35); }
#mg-members .mgm-drawer-panel {
position: absolute; top: 0; right: 0; height: 100%; width: min(580px, 100vw); background: #fff;
box-shadow: var(--mgm-shadow-lg); display: flex; flex-direction: column; animation: mgm-slide .2s ease-out;
}
@keyframes mgm-slide { from { transform: translateX(24px); opacity: .6; } to { transform: none; opacity: 1; } }
#mg-members .mgm-drawer-head { display: flex; align-items: center; gap: 10px; padding: 16px 20px; border-bottom: 1px solid var(--mgm-border); }
#mg-members .mgm-drawer-head h3 { flex: 1; font-size: 16px; }
#mg-members .mgm-drawer-head button { border: 0; background: none; font-size: 22px; line-height: 1; padding: 2px 6px; color: var(--mgm-muted); }
#mg-members .mgm-drawer-body { padding: 18px 20px 28px; overflow-y: auto; flex: 1; }
#mg-members .mgm-dsec + .mgm-dsec { margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--mgm-border); }
#mg-members .mgm-dsec h4 { margin-bottom: 10px; display: flex; align-items: center; gap: 6px; }
#mg-members .mgm-drawer-body .mgm-table-wrap { margin: 0 -20px; padding: 0 20px; }
/* ---------- 대화상자 ---------- */
#mg-members .mgm-modal { position: fixed; inset: 0; z-index: 9995; display: flex; align-items: center; justify-content: center; padding: 16px; }
#mg-members .mgm-modal-backdrop { position: absolute; inset: 0; background: rgba(15, 20, 30, .4); }
#mg-members .mgm-modal-panel {
position: relative; width: min(500px, 100%); max-height: calc(100vh - 32px); overflow-y: auto;
background: #fff; border-radius: 14px; box-shadow: var(--mgm-shadow-lg); padding: 22px; animation: mgm-in .15s ease-out;
}
#mg-members .mgm-modal-panel h3 { font-size: 17px; margin-bottom: 14px; }
#mg-members .mgm-modal-body { display: flex; flex-direction: column; gap: 12px; font-size: 14px; }
#mg-members .mgm-modal-error { margin-top: 12px; padding: 9px 12px; border-radius: 8px; font-size: 13px; background: var(--mgm-danger-soft); color: var(--mgm-danger); }
#mg-members .mgm-modal-foot { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
#mg-members .mgm-modal-foot button { min-width: 80px; height: 38px; }
@media (max-width: 1280px) {
#mg-members .mgm-filter-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 1100px) {
#mg-members .mgm-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
#mg-members .mgm-grid-2, #mg-members .mgm-grid-2.is-wide-left, #mg-members .mgm-split { 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; 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-account { margin: 0 0 0 auto; padding: 0; background: none; }
#mg-members .mgm-nav { order: 3; flex: 1 0 100%; flex-direction: row; overflow-x: auto; gap: 4px; }
#mg-members .mgm-nav-label { display: none; }
#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-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 { max-width: 100%; overflow-x: auto; }
#mg-members .mgm-bar { grid-template-columns: minmax(70px, 120px) 1fr 44px; }
#mg-members .mgm-dl { grid-template-columns: 1fr; gap: 2px 0; }
#mg-members .mgm-dl dd { margin-bottom: 8px; }
}
/* 휴대폰: 지표 카드는 2×2 로 촘촘하게 */
@media (max-width: 520px) {
#mg-members .mgm-kpis { gap: 8px; }
#mg-members .mgm-kpi { padding: 12px 14px; }
#mg-members .mgm-kpi-value { font-size: 22px; }
#mg-members .mgm-card { padding: 14px; }
#mg-members .mgm-table-wrap { margin: 0 -14px; padding: 0 14px; }
}
</style>
<!-- 로그인 -->
<div class="mgm-login-wrap" data-role="login" hidden>
<section class="mgm-card mgm-login">
<div class="mgm-brand" style="padding:0;color:var(--mgm-text)">
<span class="mgm-logo" style="color:#fff">₩</span>
<div><strong>자본주의가이드 관리자</strong><span class="mgm-muted">신청·업체 링크·회원 유입경로</span></div>
</div>
<div class="mgm-login-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">
<span class="mgm-logo">₩</span>
<div><strong>자본주의가이드</strong><span>관리자 대시보드</span></div>
</div>
<!-- 위젯 위에 사이트 헤더가 있어도 항상 보이도록 위쪽에 둔다 -->
<div class="mgm-account">
<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>
<span class="mgm-nav-label">운영</span>
<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="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>
<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>
<span class="mgm-nav-label">설정</span>
<button type="button" data-view="integration">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></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>
<button type="button" class="mgm-sync-pill" data-role="sync-pill" data-goto="integration" title="연동 관리로 이동">상태 확인 중</button>
</header>
<!-- ========== 개요 ========== -->
<section data-panel="overview" class="mgm-stack" hidden>
<div class="mgm-kpis">
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="today">
<span class="mgm-kpi-label">오늘 신청</span>
<span class="mgm-kpi-value" data-kpi="app-today">-</span>
<span class="mgm-kpi-sub" data-kpi="app-today-sub">&nbsp;</span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7">
<span class="mgm-kpi-label">최근 7일 신청</span>
<span class="mgm-kpi-value" data-kpi="app-week">-</span>
<span class="mgm-kpi-sub" data-kpi="app-week-sub">&nbsp;</span>
</button>
<button type="button" class="mgm-card mgm-kpi" data-goto="partners" data-ptn-preset="7">
<span class="mgm-kpi-label">최근 7일 업체 링크 신청</span>
<span class="mgm-kpi-value" data-kpi="ptn-week">-</span>
<span class="mgm-kpi-sub" data-kpi="ptn-week-sub">&nbsp;</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="join-week">-</span>
<span class="mgm-kpi-sub" data-kpi="join-week-sub">&nbsp;</span>
</button>
</div>
<div class="mgm-grid-2">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청 유입 상위</h3>
<span class="mgm-muted">최근 30일 · 신청 때 유입 기준</span>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30">신청 내역 →</button>
</div>
<div class="mgm-bars" data-role="ov-app-bars"><div class="mgm-muted">불러오는 중…</div></div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>업체 실적 상위</h3>
<span class="mgm-muted">최근 30일</span>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="partners" data-ptn-preset="30">업체 링크 →</button>
</div>
<div class="mgm-table-wrap">
<table>
<thead><tr><th>업체</th><th class="mgm-r">클릭</th><th class="mgm-r">신청</th><th class="mgm-r">구매</th></tr></thead>
<tbody data-role="ov-ptn-rows"></tbody>
</table>
</div>
</section>
</div>
<div class="mgm-grid-2 is-wide-left">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>최근 신청</h3>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="all">전체 보기 →</button>
</div>
<div class="mgm-table-wrap">
<table>
<thead><tr><th>신청일시</th><th>신청자</th><th>상품</th><th>업체 · 유입</th></tr></thead>
<tbody data-role="ov-recent-rows"></tbody>
</table>
</div>
</section>
<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="ov-join-bars"><div class="mgm-muted">불러오는 중…</div></div>
</section>
</div>
</section>
<!-- ========== 신청 내역 ========== -->
<section data-panel="applications" class="mgm-stack" hidden>
<section class="mgm-card">
<form data-role="app-filter" class="mgm-toolbar" autocomplete="off">
<div class="mgm-seg is-large" role="group" aria-label="보기 방식">
<button type="button" data-app-mode="product" class="is-active">상품별</button>
<button type="button" data-app-mode="partner">업체별</button>
</div>
<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>
</form>
</section>
<div class="mgm-grid-2 is-wide-left">
<section class="mgm-card">
<div class="mgm-card-head">
<h3 data-role="app-groups-title">상품별 신청</h3>
<span class="mgm-muted">행을 누르면 아래 목록과 오른쪽 집계를 그 항목으로 좁힙니다</span>
</div>
<div class="mgm-table-wrap">
<table>
<thead><tr><th data-role="app-groups-col">상품</th><th class="mgm-r">신청</th><th class="mgm-r" title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼 클릭</th></tr></thead>
<tbody data-role="app-groups"></tbody>
</table>
</div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청 유입</h3>
<span class="mgm-spacer"></span>
<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>
</div>
</div>
<div class="mgm-row" style="margin-bottom:12px">
<div class="mgm-seg" role="group" aria-label="유입 기준">
<button type="button" data-app-basis="apply_first" class="is-active" title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관)">신청 때 유입</button>
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
</div>
</div>
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
<p class="mgm-muted" data-role="app-stats-desc" style="margin-top:10px;font-size:12px"></p>
</section>
</div>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>신청자</h3>
<span data-role="app-chips" class="mgm-chiprow"></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 title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼</th>
<th title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관)">신청 때 유입</th><th>가입 경로</th>
</tr>
</thead>
<tbody data-role="app-rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="app-empty" hidden><b>신청 기록이 없습니다</b>기간이나 선택한 항목을 바꿔 보세요.</div>
<div class="mgm-more"><button type="button" data-action="app-more" hidden>더 보기</button></div>
</section>
</section>
<!-- ========== 업체 링크 ========== -->
<section data-panel="partners" class="mgm-stack" hidden>
<section class="mgm-card">
<div class="mgm-toolbar">
<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>
<button type="button" class="mgm-primary" data-action="ptn-new-link">+ 링크 발급</button>
</div>
</section>
<div class="mgm-kpis is-compact">
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">링크 클릭</span><span class="mgm-kpi-value" data-kpi="ptn-clicks">-</span><span class="mgm-kpi-sub">사람이 누른 것만 (미리보기·봇 제외)</span></div>
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">신청</span><span class="mgm-kpi-value" data-kpi="ptn-apps">-</span><span class="mgm-kpi-sub" data-kpi="ptn-apps-sub">&nbsp;</span></div>
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">구매</span><span class="mgm-kpi-value" data-kpi="ptn-buys">-</span><span class="mgm-kpi-sub">결제완료 (환불·취소 제외)</span></div>
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">업체</span><span class="mgm-kpi-value" data-kpi="ptn-count">-</span><span class="mgm-kpi-sub" data-kpi="ptn-count-sub">&nbsp;</span></div>
</div>
<details class="mgm-help">
<summary>실적은 어떻게 집계되나요?</summary>
<ul>
<li>업체에 줄 링크는 <b>www.moneyguider.co.kr/go?c=코드</b> 형식입니다. 아임웹에 주소가 'go' 인 페이지가 있어야 동작합니다.</li>
<li>링크를 누른 뒤 <b>30일 안에</b> 신청·구매하면, 그 상품의 링크 중 <b>마지막으로 누른</b> 업체 실적으로 잡습니다.</li>
<li>구매는 결제완료 주문만 셉니다. 환불·취소되면 빠집니다. 아임웹 주문은 1시간마다 확인합니다.</li>
<li>링크로 들어온 방문에는 업체 UTM(유입원=업체 이름, 매체=partner, 캠페인=링크 코드)이 붙어 신청 유입·가입 경로에도 업체가 나옵니다.</li>
<li>링크를 누른 기기와 신청·구매한 기기가 다르거나, 비회원 주문이면 추적되지 않습니다.</li>
</ul>
</details>
<div class="mgm-split">
<section class="mgm-card">
<div class="mgm-card-head">
<h3>업체</h3>
<span class="mgm-muted" data-role="ptn-partner-total"></span>
<span class="mgm-spacer"></span>
<button type="button" class="mgm-sm" data-action="ptn-new-partner">+ 업체 추가</button>
</div>
<div class="mgm-plist" data-role="ptn-partner-list"><div class="mgm-muted">불러오는 중…</div></div>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3 data-role="ptn-link-title">전체 링크</h3>
<span class="mgm-muted" data-role="ptn-link-total"></span>
</div>
<div class="mgm-table-wrap">
<table>
<thead>
<tr>
<th>업체 · 상품</th><th>업체에 줄 링크</th>
<th class="mgm-r">클릭</th><th class="mgm-r">신청</th><th class="mgm-r">구매</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>
<b>발급한 링크가 없습니다</b>업체를 고르고 상품 페이지로 보내는 링크를 발급하세요.
<br><button type="button" class="mgm-primary" data-action="ptn-new-link">+ 링크 발급</button>
</div>
<div class="mgm-more"><button type="button" data-action="ptn-link-more" hidden>더 보기</button></div>
</section>
</div>
</section>
<!-- ========== 회원 ========== -->
<section data-panel="members" class="mgm-stack" hidden>
<section class="mgm-card">
<form data-role="filter-form" autocomplete="off">
<div class="mgm-toolbar">
<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>
<button type="button" data-action="toggle-filters" aria-expanded="false">유입경로 필터 <span data-role="filter-count"></span></button>
</div>
<div class="mgm-chiprow mgm-divider" role="group" aria-label="회원 그룹">
<span class="mgm-chiprow-label">회원 그룹</span>
<span data-role="group-chips" class="mgm-chiprow"><span class="mgm-muted">불러오는 중…</span></span>
</div>
<div class="mgm-filter-grid mgm-divider" data-role="filter-advanced" hidden>
<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-chiprow" data-role="active-wrap" style="margin-top:12px" hidden>
<span data-role="active-filters" class="mgm-chiprow"></span>
<button type="button" class="mgm-link" data-action="reset">모두 지우기</button>
</div>
</form>
</section>
<section class="mgm-card">
<div class="mgm-card-head">
<h3>회원</h3>
<span class="mgm-muted">행을 누르면 유입경로 상세를 봅니다</span>
<span class="mgm-spacer"></span>
<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></tr>
</thead>
<tbody data-role="rows"></tbody>
</table>
</div>
<div class="mgm-empty" data-role="empty" hidden><b>조건에 맞는 회원이 없습니다</b>필터를 바꾸거나 지워 보세요.</div>
<div class="mgm-more"><button type="button" data-action="more" hidden>더 보기</button></div>
</section>
</section>
<!-- ========== 연동 관리 ========== -->
<section data-panel="integration" class="mgm-stack" hidden>
<div class="mgm-grid-2">
<section class="mgm-card">
<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 is-danger" 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-spacer"></span>
<button type="button" class="mgm-primary" data-action="sync" hidden>지금 동기화</button>
</div>
<dl class="mgm-dl" data-role="sync-detail"></dl>
</section>
</div>
<section class="mgm-card">
<div class="mgm-card-head"><h3>필요한 아임웹 권한</h3><span class="mgm-muted">개발자센터 앱 설정에 모두 켜져 있어야 연결됩니다</span></div>
<div class="mgm-row">
<span class="mgm-badge">사이트 정보 읽기∙쓰기</span>
<span class="mgm-badge">회원 정보 읽기</span>
<span class="mgm-badge">상품 읽기</span>
<span class="mgm-badge">주문 읽기</span>
</div>
<p class="mgm-muted" style="margin-top:10px">권한을 바꾼 뒤에는 위 '아임웹 연결'을 다시 해야 새 권한이 적용됩니다. 상품 불러오기·구매 집계가 IMWEB_006 으로 실패하면 다시 연결하세요.</p>
</section>
</section>
</main>
</div>
<!-- 알림 -->
<div class="mgm-toasts" data-role="toasts" aria-live="polite"></div>
<!-- 상세 패널 -->
<div class="mgm-drawer" data-role="drawer" hidden>
<div class="mgm-drawer-backdrop" data-drawer-close></div>
<aside class="mgm-drawer-panel" role="dialog" aria-modal="true" aria-labelledby="mgm-drawer-title">
<div class="mgm-drawer-head">
<h3 id="mgm-drawer-title" data-role="drawer-title"></h3>
<button type="button" data-drawer-close aria-label="닫기">×</button>
</div>
<div class="mgm-drawer-body" data-role="drawer-body"></div>
</aside>
</div>
<!-- 대화상자 -->
<div class="mgm-modal" data-role="modal" hidden>
<div class="mgm-modal-backdrop" data-modal-cancel></div>
<form class="mgm-modal-panel" role="dialog" aria-modal="true" aria-labelledby="mgm-modal-title" autocomplete="off" novalidate>
<h3 id="mgm-modal-title" data-role="modal-title"></h3>
<div class="mgm-modal-body" data-role="modal-body"></div>
<div class="mgm-modal-error" data-role="modal-error" hidden></div>
<div class="mgm-modal-foot">
<button type="button" data-modal-cancel data-role="modal-cancel">취소</button>
<button type="submit" class="mgm-primary" data-role="modal-ok">확인</button>
</div>
</form>
</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 TOP_SIZE = 8;
var PTN_TOP = 6;
var SYNC_POLL_MS = 3000;
var TOAST_OK_MS = 4000;
var TOAST_ERROR_MS = 9000;
var SEARCH_DEBOUNCE_MS = 350;
var LIST_LIMIT = 100; // 업체·상품 선택 목록은 한 번에 받는다 (API 최대값)
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: '신청·업체 실적·가입 현황을 한눈에 봅니다' },
applications: { title: '신청 내역', desc: '강의 신청을 상품별·업체별로 보고, 신청자와 유입경로를 확인합니다' },
partners: { title: '업체 링크', desc: '업체별 링크를 발급하고 클릭·신청·구매 실적을 봅니다' },
members: { 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); }
function closest(el, sel) { return el && el.closest ? el.closest(sel) : null; }
var state = {
admin: null,
view: 'overview',
status: null,
pollTimer: null,
lastRunStatus: null,
// 회원
cursor: null, listSeq: 0, loading: false,
suggestLoaded: false, suggestions: {}, appliedFilters: {},
groupCode: '', // 선택한 회원 그룹 (폼 reset 으로 지워지지 않게 상태로 둔다)
groups: null, groupsStale: true,
memberRows: {}, // 상세 패널용 — 회원 코드 → 회원
// 신청 — mode: product | partner, group: 고른 상품번호 / 업체 ID('none' = 업체 없음)
app: {
range: 'all', mode: 'product', group: null, groupLabel: '',
cursor: null, loading: false, seq: 0, groupsSeq: 0,
basis: 'apply_first', statsGroup: 'source', statsSeq: 0, inflowFilter: null,
rows: {}
},
// 업체 링크
ptn: {
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
partners: [], products: null, syncTimer: null, links: {},
conv: { linkId: '', type: '', cursor: null, loading: false, seq: 0 }
}
};
/* ===================== 공통 ===================== */
// 회원 이름 등은 사용자가 입력한 값이므로 화면에 넣기 전에 반드시 이스케이프한다
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-faint">-</span>'; }
function num(n) { return Number(n || 0).toLocaleString(); }
function pad(n) { return n < 10 ? '0' + n : String(n); }
function pct(part, whole) { return whole ? Math.round((part / whole) * 100) + '%' : '-'; }
// 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());
}
// ISO → KST 'MM-DD HH:mm' (표에서 짧게)
function kstShort(iso) { return iso ? kstDateTime(iso).slice(5) : ''; }
// 표의 일시 칸 — 올해면 'MM-DD HH:mm', 다른 해면 'YYYY-MM-DD' (마우스를 올리면 전체 일시)
function whenCell(iso) {
if (!iso) return '<span class="mgm-faint">-</span>';
var full = kstDateTime(iso);
var text = full.slice(0, 4) === kstToday(0).slice(0, 4) ? full.slice(5) : full.slice(0, 10);
return '<span class="mgm-num" title="' + esc(full) + '">' + esc(text) + '</span>';
}
// 연락처 — 숫자만 있으면 010-1234-5678 형태로 끊어 보여준다
function phone(value) {
if (!value) return '';
var d = String(value).replace(/\D/g, '');
if (/^01\d{8,9}$/.test(d)) return d.slice(0, 3) + '-' + d.slice(3, d.length - 4) + '-' + d.slice(-4);
if (/^02\d{7,8}$/.test(d)) return '02-' + d.slice(2, d.length - 4) + '-' + d.slice(-4);
return String(value);
}
// 오늘(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: '' };
}
// 기간 버튼 → API 쿼리 ({ [fromKey], [toKey] } — 끝 날짜 하루 전체 포함)
function periodQuery(range, fromKey, toKey) {
var r = rangeDates(range), q = {};
if (r.from) q[fromKey] = kstDayStartIso(r.from);
if (r.to) q[toKey] = 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 setSeg(selector, attr, value) {
each($all(selector), function (b) { b.classList.toggle('is-active', b.getAttribute(attr) === value); });
}
function setKpi(name, value) { var el = $('[data-kpi="' + name + '"]'); if (el) el.textContent = value; }
function storageGet(key) { try { return localStorage.getItem(key); } catch (e) { return null; } }
function storageSet(key, value) { try { localStorage.setItem(key, value); } catch (e) { /* 무시 */ } }
/* ===================== 알림 (토스트) ===================== */
function toast(html, kind) {
var box = $('[data-role="toasts"]');
var el = document.createElement('div');
el.className = 'mgm-toast' + (kind === 'error' ? ' is-error' : '');
el.setAttribute('role', kind === 'error' ? 'alert' : 'status');
el.innerHTML = '<div>' + html + '</div><button type="button" aria-label="알림 닫기">×</button>';
el.querySelector('button').addEventListener('click', function () { el.remove(); });
box.appendChild(el);
setTimeout(function () { if (el.parentNode) el.remove(); }, kind === 'error' ? TOAST_ERROR_MS : TOAST_OK_MS);
}
function errorText(err) {
var trace = err && err.traceId ? ' <span class="mgm-trace">(추적 ID ' + esc(err.traceId) + ')</span>' : '';
return esc((err && err.message) || '요청을 처리하지 못했습니다.') + trace;
}
function notify(html) { toast(html, 'ok'); }
function errorNotice(err) { toast(errorText(err), 'error'); }
/* ===================== 대화상자 ===================== */
var modal = { el: $('[data-role="modal"]'), opts: null, resolve: null, busy: false };
var modalForm = modal.el.querySelector('form');
// opts: { title, html, confirmText, cancelText, danger, hideCancel, submit(values) → Promise }
function openModal(opts) {
return new Promise(function (resolve) {
modal.opts = opts; modal.resolve = resolve; modal.busy = false;
$('[data-role="modal-title"]').textContent = opts.title;
$('[data-role="modal-body"]').innerHTML = opts.html || '';
$('[data-role="modal-error"]').hidden = true;
var ok = $('[data-role="modal-ok"]');
ok.textContent = opts.confirmText || '확인';
ok.className = opts.danger ? 'mgm-danger' : 'mgm-primary';
ok.disabled = false;
var cancel = $('[data-role="modal-cancel"]');
cancel.textContent = opts.cancelText || '취소';
cancel.hidden = !!opts.hideCancel;
modal.el.hidden = false;
if (opts.onOpen) opts.onOpen(modalForm);
var first = modalForm.querySelector('.mgm-modal-body input:not([readonly]), .mgm-modal-body select, .mgm-modal-body textarea');
(first || ok).focus();
});
}
function closeModal(result) {
if (modal.el.hidden) return;
modal.el.hidden = true;
var resolve = modal.resolve;
modal.resolve = null; modal.opts = null;
if (resolve) resolve(result);
}
function modalValues() {
var values = {};
each(modalForm.querySelectorAll('[name]'), function (el) { values[el.name] = el.value; });
return values;
}
modalForm.addEventListener('submit', function (e) {
e.preventDefault();
if (modal.busy || !modal.opts) return;
var values = modalValues();
if (!modal.opts.submit) { closeModal(values); return; }
var ok = $('[data-role="modal-ok"]');
modal.busy = true; ok.disabled = true;
$('[data-role="modal-error"]').hidden = true;
Promise.resolve(modal.opts.submit(values)).then(function (result) {
modal.busy = false;
closeModal(result === undefined ? values : result);
}, function (err) {
modal.busy = false; ok.disabled = false;
var box = $('[data-role="modal-error"]');
box.innerHTML = typeof err === 'string' ? esc(err) : errorText(err);
box.hidden = false;
});
});
each(modal.el.querySelectorAll('[data-modal-cancel]'), function (el) {
el.addEventListener('click', function () { if (!modal.busy) closeModal(null); });
});
function confirmModal(title, message, confirmText, danger) {
return openModal({ title: title, html: '<p>' + message + '</p>', confirmText: confirmText, danger: danger });
}
/* ===================== 상세 패널 ===================== */
var drawer = $('[data-role="drawer"]');
function openDrawer(title, html) {
$('[data-role="drawer-title"]').textContent = title;
$('[data-role="drawer-body"]').innerHTML = html;
drawer.hidden = false;
$('[data-role="drawer-body"]').scrollTop = 0;
}
function closeDrawer() {
drawer.hidden = true;
if (state.ptn.conv.linkId) { state.ptn.conv.linkId = ''; markSelectedLink(); }
}
each(drawer.querySelectorAll('[data-drawer-close]'), function (el) { el.addEventListener('click', closeDrawer); });
function dlHtml(pairs) {
return '<dl class="mgm-dl">' + pairs.map(function (p) {
return '<dt>' + esc(p[0]) + '</dt><dd>' + (p[1] || '<span class="mgm-faint">-</span>') + '</dd>';
}).join('') + '</dl>';
}
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
if (!modal.el.hidden) { if (!modal.busy) closeModal(null); }
else if (!drawer.hidden) closeDrawer();
});
/* ===================== 세션 토큰 ===================== */
// 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;
closeDrawer();
$('[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"]');
loginNotice.hidden = true;
rawRequest('POST', '/admin/sessions', {
username: form.username.value.trim(),
password: form.password.value
}, false).then(function (r) {
form.password.value = '';
if (!r.ok) {
loginNotice.innerHTML = r.status === 401 ? '아이디 또는 비밀번호가 올바르지 않습니다.' : errorText(r.error);
loginNotice.hidden = false;
return;
}
showApp(storeIssued(r.data));
});
});
/* ===================== 화면 전환 ===================== */
function go(view) {
state.view = view;
storageSet(VIEW_KEY, view);
closeDrawer();
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') loadApplicationsView();
else if (view === 'partners') loadPartnersView();
else if (view === 'integration') renderIntegration();
}
each($all('[data-view]'), function (b) {
b.addEventListener('click', function () { go(b.getAttribute('data-view')); });
});
// 지표 카드·링크 → 다른 화면 (필요하면 필터를 미리 채운다)
root.addEventListener('click', function (e) {
var link = closest(e.target, '[data-goto]');
if (!link || !root.contains(link)) return;
var target = link.getAttribute('data-goto');
if (target === 'applications' && link.hasAttribute('data-app-preset')) {
openApplications({ range: link.getAttribute('data-app-preset') });
return;
}
if (target === 'partners' && link.hasAttribute('data-ptn-preset')) {
openPartners('', link.getAttribute('data-ptn-preset'));
return;
}
if (target === 'members' && link.hasAttribute('data-preset')) {
var preset = link.getAttribute('data-preset');
resetFilters();
var range = preset === '7' ? '7' : 'all';
var r = rangeDates(range);
filterForm.from.value = r.from; filterForm.to.value = r.to;
if (preset === 'tracked') filterForm.inflowStatus.value = 'at_join';
setSeg('[data-range]', 'data-range', range);
}
go(target);
});
/* ===================== 연동 상태 ===================== */
function syncPill(s) {
if (!s.isConfigured) return ['서버 설정 필요', 'is-warn'];
if (!s.isConnected) return ['아임웹 미연결', 'is-warn'];
var run = s.latestRun;
if (run && run.status === 'running') return ['회원 동기화 중… ' + num(run.fetchedCount) + '명', 'is-busy'];
if (run && run.status === 'failed') return ['마지막 동기화 실패', 'is-warn'];
return [s.latestSucceededRun ? '회원 동기화 ' + kstShort(s.latestSucceededRun.finishedAt) : '동기화 전', 'is-ok'];
}
function loadStatus() {
return api('GET', '/admin/imweb/connection').then(function (s) {
state.status = s;
var running = s.latestRun && s.latestRun.status === 'running';
var pill = syncPill(s), pillEl = $('[data-role="sync-pill"]');
pillEl.textContent = pill[0];
pillEl.className = 'mgm-sync-pill ' + pill[1];
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 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 = dlHtml([
['연결 시각', s.connectedAt ? esc(kstDateTime(s.connectedAt)) : ''],
['동기화된 회원', esc(num(s.memberCount)) + '명']
]).replace(/^<dl class="mgm-dl">|<\/dl>$/g, '');
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-faint">아직 없음</span>';
$('[data-role="sync-detail"]').innerHTML = dlHtml([
['최근 실행', runText],
['마지막 성공', ok ? esc(kstDateTime(ok.finishedAt)) + ' · ' + esc(num(ok.fetchedCount)) + '명' +
(ok.removedCount ? ' · 탈퇴 정리 ' + esc(num(ok.removedCount)) + '명' : '') : ''],
['자동 동기화', '서버 설정 주기마다 (기본 60분)']
]).replace(/^<dl class="mgm-dl">|<\/dl>$/g, '');
}
root.addEventListener('click', function (e) {
var btn = closest(e.target, '[data-action]');
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') {
confirmModal('아임웹 연결 해제', '아임웹 연결을 해제할까요? 동기화된 회원 목록은 그대로 남습니다.', '연결 해제', true)
.then(function (ok) {
if (!ok) 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 () {
notify('회원 동기화를 시작했습니다. 끝나면 화면이 자동으로 새로고침됩니다.');
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();
} else if (action === 'toggle-filters') {
toggleAdvanced();
}
});
/* ===================== 막대 그래프 ===================== */
function barsHtml(items, clickable, selectedKey, unit) {
if (!items.length) return '<div class="mgm-empty" style="padding:20px">집계할 데이터가 없습니다.</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 w = Math.max(2, Math.round((i.count / max) * 100));
var disabled = !clickable || i.key === null;
var selected = selectedKey !== undefined && selectedKey !== null && i.key === selectedKey;
return '<button type="button" class="mgm-bar' + (selected ? ' is-selected' : '') + '" data-key="' + (i.key === null ? '' : esc(i.key)) + '"' +
(disabled ? ' disabled' : '') + ' title="' + label + ' · ' + num(i.count) + (unit || '') + '">' +
'<span class="mgm-bar-label">' + label + '</span>' +
'<span class="mgm-bar-track"><span class="mgm-bar-fill" style="width:' + w + '%"></span></span>' +
'<span class="mgm-bar-count">' + num(i.count) + '</span></button>';
}).join('');
}
/* ===================== 유입·회원 표시 공통 ===================== */
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 inflowText(inf) {
if (!inf || !inf.source) return '<span class="mgm-faint">-</span>';
return '<span class="mgm-cell-main" style="font-weight:500">' + esc(inf.source) + (inf.medium ? ' <span class="mgm-faint">/</span> ' + esc(inf.medium) : '') + '</span>' +
(inf.campaign ? '<span class="mgm-cell-sub">' + esc(inf.campaign) + '</span>' : '');
}
// 가입 경로 칸 — 가입 때 기록된 값만 가입 경로다
function joinInflowCell(m) {
if (!m) return '<span class="mgm-faint">회원 정보 없음</span>';
var inf = m.inflow;
if (inf.status !== 'at_join') return STATUS_BADGE[inf.status] || '';
return inflowText(inf);
}
function groupsHtml(m) {
return m && m.groups && m.groups.length
? m.groups.map(function (g) { return '<span class="mgm-group-badge">' + esc(g.title) + '</span>'; }).join('')
: '<span class="mgm-faint">-</span>';
}
function personCell(name, sub) {
return '<span class="mgm-cell-main">' + dash(name) + '</span>' + (sub ? '<span class="mgm-cell-sub">' + esc(sub) + '</span>' : '');
}
// 상세 패널의 회원·가입 경로 부분
function memberSectionsHtml(m) {
if (!m) return '<section class="mgm-dsec"><h4>회원</h4><p class="mgm-muted">회원 정보 없음 — 탈퇴했거나 아직 동기화되지 않았습니다.</p></section>';
var inf = m.inflow;
return '<section class="mgm-dsec"><h4>회원</h4>' + dlHtml([
['이름', dash(m.name)],
['아이디', esc(m.uid)],
['이메일', esc(m.email)],
['연락처', esc(phone(m.callnum))],
['가입일시', esc(kstDateTime(m.joinTime))],
['가입 방식', m.socialProvider ? esc(SOCIAL_LABEL[m.socialProvider] || m.socialProvider) : '일반'],
['최근 로그인', esc(kstDateTime(m.lastLoginTime))],
['회원 그룹', groupsHtml(m)]
]) + '</section>' +
'<section class="mgm-dsec"><h4>가입 경로 ' + (STATUS_BADGE[inf.status] || '') +
(inf.origin === 'import' ? ' <span class="mgm-badge" title="아임웹 회원 엑셀에서 가져온 값">엑셀</span>' : '') + '</h4>' + dlHtml([
['유입원', esc(inf.source)],
['매체', esc(inf.medium)],
['캠페인', esc(inf.campaign)],
['콘텐츠', esc(inf.content)],
['검색어', esc(inf.term)],
['광고 클릭 ID', esc(inf.clickId)],
['외부 리퍼러', esc(inf.referrer)],
['첫 랜딩', esc(inf.landingPath) + (inf.productId ? ' <span class="mgm-faint">#' + esc(inf.productId) + '</span>' : '')],
['최초 유입일', esc(inf.firstTouchDate)],
['기록일', esc(inf.recordedDate)]
]) + '</section>';
}
/* ===================== 개요 ===================== */
function renderOverviewStatus() {
var s = state.status;
if (s) setKpi('join-week-sub', '전체 회원 ' + num(s.memberCount) + '명');
}
function loadOverview() {
renderOverviewStatus();
api('GET', '/admin/applications/groups' + toQuery(periodQuery('today', 'appliedFrom', 'appliedTo'))).then(function (g) {
setKpi('app-today', num(g.total));
setKpi('app-today-sub', '카톡 버튼 클릭 ' + num(g.kakaoTotal) + '명');
}).catch(errorNotice);
api('GET', '/admin/applications/groups' + toQuery(periodQuery('7', 'appliedFrom', 'appliedTo'))).then(function (g) {
setKpi('app-week', num(g.total));
setKpi('app-week-sub', '카톡 버튼 클릭률 ' + pct(g.kakaoTotal, g.total));
}).catch(errorNotice);
api('GET', '/admin/partners' + toQuery(Object.assign({ limit: LIST_LIMIT }, periodQuery('7', 'from', 'to')))).then(function (page) {
var t = sumStats(page.items);
setKpi('ptn-week', num(t.applicationCount));
setKpi('ptn-week-sub', '클릭 ' + num(t.clickCount) + ' · 구매 ' + num(t.purchaseCount));
}).catch(errorNotice);
api('GET', '/admin/members/inflow-stats' + toQuery(Object.assign({ groupBy: 'source' }, periodQuery('7', 'joinedFrom', 'joinedTo')))).then(function (s) {
var c = s.statusCounts;
setKpi('join-week', num(c.atJoin + c.afterJoin + c.none));
}).catch(errorNotice);
api('GET', '/admin/applications/inflow-stats' + toQuery(Object.assign({ basis: 'apply_first', groupBy: 'source' }, periodQuery('30', 'appliedFrom', 'appliedTo')))).then(function (s) {
$('[data-role="ov-app-bars"]').innerHTML = barsHtml(s.items.slice(0, TOP_SIZE), true, null, '건');
}).catch(errorNotice);
api('GET', '/admin/partners' + toQuery(Object.assign({ limit: LIST_LIMIT }, periodQuery('30', 'from', 'to')))).then(function (page) {
var top = page.items.slice().sort(function (a, b) {
return (b.stats.applicationCount + b.stats.purchaseCount) - (a.stats.applicationCount + a.stats.purchaseCount) ||
b.stats.clickCount - a.stats.clickCount;
}).slice(0, PTN_TOP);
$('[data-role="ov-ptn-rows"]').innerHTML = top.length ? top.map(function (p) {
return '<tr class="is-clickable" data-ov-partner="' + esc(p.id) + '">' +
'<td><span class="mgm-cell-main">' + esc(p.name) + '</span></td>' +
'<td class="mgm-r">' + num(p.stats.clickCount) + '</td><td class="mgm-r">' + num(p.stats.applicationCount) + '</td>' +
'<td class="mgm-r">' + num(p.stats.purchaseCount) + '</td></tr>';
}).join('') : '<tr><td colspan="4" class="mgm-empty">등록된 업체가 없습니다.</td></tr>';
}).catch(errorNotice);
api('GET', '/admin/applications' + toQuery({ limit: RECENT_SIZE })).then(function (page) {
$('[data-role="ov-recent-rows"]').innerHTML = page.items.length ? page.items.map(function (a) {
state.app.rows[a.id] = a;
var m = a.member;
var inf = a.inflow;
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
'<td>' + whenCell(a.appliedAt) + '</td>' +
'<td>' + personCell(m ? m.name : a.memberUid, '') + '</td>' +
'<td class="mgm-wrap">' + dash(a.productName) + '</td>' +
'<td>' + (a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span>' : (inf && inf.source ? esc(inf.source) : '<span class="mgm-faint">-</span>')) + '</td>' +
'</tr>';
}).join('') : '<tr><td colspan="4" class="mgm-empty">아직 신청이 없습니다.</td></tr>';
}).catch(errorNotice);
api('GET', '/admin/members/inflow-stats' + toQuery({ groupBy: 'source' })).then(function (s) {
$('[data-role="ov-join-bars"]').innerHTML = barsHtml(s.items.slice(0, TOP_SIZE), true, null, '명');
}).catch(errorNotice);
}
$('[data-role="ov-app-bars"]').addEventListener('click', function (e) {
var bar = closest(e.target, '.mgm-bar');
if (!bar || bar.disabled) return;
openApplications({ range: '30', inflowFilter: { basis: 'apply_first', field: 'source', value: bar.getAttribute('data-key') } });
});
$('[data-role="ov-join-bars"]').addEventListener('click', function (e) {
var bar = closest(e.target, '.mgm-bar');
if (!bar || bar.disabled) return;
openMembersWith('source', bar.getAttribute('data-key'), 'all');
});
$('[data-role="ov-ptn-rows"]').addEventListener('click', function (e) {
var row = closest(e.target, '[data-ov-partner]');
if (row) openPartners(row.getAttribute('data-ov-partner'), '30');
});
$('[data-role="ov-recent-rows"]').addEventListener('click', function (e) {
var row = closest(e.target, '[data-app-row]');
if (row) openApplicationDrawer(row.getAttribute('data-app-row'));
});
/* ===================== 신청 내역 ===================== */
var appForm = $('[data-role="app-filter"]');
var APP_BASIS_LABEL = { apply_first: '신청 때 유입', join: '가입 경로' };
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
// 다른 화면에서 신청 내역 열기 — 기간·유입 필터를 미리 채운다
function openApplications(opts) {
var app = state.app;
app.range = opts.range || 'all';
app.group = null; app.groupLabel = '';
app.inflowFilter = opts.inflowFilter || null;
setSeg('[data-app-range]', 'data-app-range', app.range);
go('applications');
}
function loadApplicationsView() {
setSeg('[data-app-mode]', 'data-app-mode', state.app.mode);
setSeg('[data-app-range]', 'data-app-range', state.app.range);
loadAppGroups();
loadApplications(true);
}
// 목록·집계 공통 — 기간 + 고른 상품/업체
function appScope() {
var app = state.app, q = periodQuery(app.range, 'appliedFrom', 'appliedTo');
if (app.group !== null) {
if (app.mode === 'product') q.productIdx = app.group;
else q.partnerId = app.group;
}
return q;
}
function appFilters() {
var q = appScope();
var text = appForm.q.value.trim();
if (text) q.q = text;
var inf = state.app.inflowFilter;
if (inf) { q.inflowBasis = inf.basis; q.inflowField = inf.field; q.inflowValue = inf.value; }
return q;
}
// 상품별·업체별 요약 표
function loadAppGroups() {
var app = state.app;
var seq = ++app.groupsSeq;
var isPartner = app.mode === 'partner';
$('[data-role="app-groups-title"]').textContent = isPartner ? '업체별 신청' : '상품별 신청';
$('[data-role="app-groups-col"]').textContent = isPartner ? '업체' : '상품';
api('GET', '/admin/applications/groups' + toQuery(Object.assign({ by: app.mode }, periodQuery(app.range, 'appliedFrom', 'appliedTo')))).then(function (g) {
if (seq !== app.groupsSeq) return;
var rows = [{ group: null, label: isPartner ? '전체 신청' : '전체 상품', count: g.total, kakao: g.kakaoTotal, isAll: true }]
.concat(g.items.map(function (i) {
return {
group: i.key === null ? 'none' : i.key,
label: i.key === null ? '업체 링크 없이 신청' : (i.label || (isPartner ? '(삭제된 업체)' : '상품 #' + i.key)),
sub: !isPartner && i.key ? '#' + i.key : (i.key === null ? '직접·광고 등 업체 링크를 거치지 않음' : ''),
count: i.count, kakao: i.kakaoCount, isNone: i.key === null
};
}));
$('[data-role="app-groups"]').innerHTML = rows.map(function (r) {
var selected = r.group === app.group;
return '<tr class="is-clickable' + (selected ? ' is-selected' : '') + '" data-app-groupkey="' + esc(r.group === null ? '' : r.group) + '" data-label="' + esc(r.label) + '">' +
'<td class="mgm-wrap"><span class="mgm-cell-main"' + (r.isNone ? ' style="font-weight:500;color:var(--mgm-muted)"' : '') + '>' + esc(r.label) + '</span>' +
(r.sub ? '<span class="mgm-cell-sub">' + esc(r.sub) + '</span>' : '') + '</td>' +
'<td class="mgm-r"><b>' + num(r.count) + '</b></td>' +
'<td class="mgm-r">' + num(r.kakao) + '<span class="mgm-rate">' + pct(r.kakao, r.count) + '</span></td></tr>';
}).join('');
}).catch(function (err) { if (seq === app.groupsSeq) errorNotice(err); });
}
function loadAppStats() {
var app = state.app;
var seq = ++app.statsSeq;
var q = Object.assign({ basis: app.basis, groupBy: app.statsGroup }, appScope());
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
if (seq !== app.statsSeq) return;
var inf = app.inflowFilter;
var selected = inf && inf.basis === app.basis && inf.field === app.statsGroup ? inf.value : null;
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, 10), true, selected, '건');
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' + APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 목록을 거릅니다';
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
}
function renderAppChips() {
var app = state.app, chips = [];
if (app.group !== null) {
chips.push('<span class="mgm-active-chip"><b>' + (app.mode === 'product' ? '상품' : '업체') + '</b> ' + esc(app.groupLabel) +
'<button type="button" data-app-clear="group" aria-label="선택 지우기">×</button></span>');
}
var inf = app.inflowFilter;
if (inf) {
chips.push('<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>');
}
$('[data-role="app-chips"]').innerHTML = chips.join('');
}
function appRowHtml(a) {
var m = a.member;
state.app.rows[a.id] = a;
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
'<td>' + whenCell(a.appliedAt) + '</td>' +
'<td>' + (m ? personCell(m.name, m.uid) : personCell(a.memberUid, '회원 정보 없음')) + '</td>' +
'<td>' + dash(phone(m && m.callnum)) + '</td>' +
'<td class="mgm-wrap">' + dash(a.productName) + '<span class="mgm-cell-sub">#' + esc(a.productIdx) + '</span></td>' +
'<td>' + (a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span>' : '<span class="mgm-faint">-</span>') + '</td>' +
'<td>' + (a.kakaoClickedAt
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + ' 에 버튼을 눌렀습니다 (실제 입장 여부는 알 수 없음)">클릭</span>'
: '<span class="mgm-faint">-</span>') + '</td>' +
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
'<td class="mgm-wrap">' + joinInflowCell(m) + '</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; loadAppStats(); renderAppChips(); }
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) + '건' + (page.pageInfo.hasNext ? ' 중 ' + 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;
});
}
function openApplicationDrawer(id) {
var a = state.app.rows[id];
if (!a) return;
var inf = a.inflow || {};
var html = '<section class="mgm-dsec"><h4>신청</h4>' + dlHtml([
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
['신청일시', esc(kstDateTime(a.appliedAt))],
['업체', a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span> 업체 링크 실적' : '업체 링크를 거치지 않음'],
['카톡 버튼', a.kakaoClickedAt ? '눌렀음 · ' + esc(kstDateTime(a.kakaoClickedAt)) + ' <span class="mgm-faint">(실제 입장 여부는 알 수 없음)</span>' : '누르지 않음']
]) + '</section>' +
'<section class="mgm-dsec"><h4>신청 때 유입</h4>' + dlHtml([
['유입원', esc(inf.source)],
['매체', esc(inf.medium)],
['캠페인', esc(inf.campaign)],
['콘텐츠', esc(inf.content)]
]) + '</section>' + memberSectionsHtml(a.member);
openDrawer((a.member ? a.member.name : a.memberUid) + ' 님의 신청', html);
}
// 보기 방식 (상품별 / 업체별)
each($all('[data-app-mode]'), function (b) {
b.addEventListener('click', function () {
var mode = b.getAttribute('data-app-mode');
if (mode === state.app.mode) return;
state.app.mode = mode;
state.app.group = null; state.app.groupLabel = '';
setSeg('[data-app-mode]', 'data-app-mode', mode);
loadAppGroups();
loadApplications(true);
});
});
each($all('[data-app-range]'), function (b) {
b.addEventListener('click', function () {
state.app.range = b.getAttribute('data-app-range');
setSeg('[data-app-range]', 'data-app-range', state.app.range);
loadAppGroups();
loadApplications(true);
});
});
// 요약 표의 행 → 그 상품/업체로 좁힌다 (다시 누르거나 '전체'를 누르면 해제)
$('[data-role="app-groups"]').addEventListener('click', function (e) {
var row = closest(e.target, '[data-app-groupkey]');
if (!row) return;
var key = row.getAttribute('data-app-groupkey') || null;
var app = state.app;
app.group = key === null || key === app.group ? null : key;
app.groupLabel = app.group === null ? '' : row.getAttribute('data-label');
each($all('[data-app-groupkey]'), function (tr) {
tr.classList.toggle('is-selected', (tr.getAttribute('data-app-groupkey') || null) === app.group);
});
loadApplications(true);
});
each($all('[data-app-basis]'), function (b) {
b.addEventListener('click', function () {
state.app.basis = b.getAttribute('data-app-basis');
setSeg('[data-app-basis]', 'data-app-basis', state.app.basis);
loadAppStats();
});
});
each($all('[data-app-group]'), function (b) {
b.addEventListener('click', function () {
state.app.statsGroup = b.getAttribute('data-app-group');
setSeg('[data-app-group]', 'data-app-group', state.app.statsGroup);
loadAppStats();
});
});
// 막대 → 같은 기준·항목·값으로 목록을 거른다 (다시 누르면 해제)
$('[data-role="app-stats-bars"]').addEventListener('click', function (e) {
var bar = closest(e.target, '.mgm-bar');
if (!bar || bar.disabled) return;
var app = state.app, value = bar.getAttribute('data-key'), cur = app.inflowFilter;
var same = cur && cur.basis === app.basis && cur.field === app.statsGroup && cur.value === value;
app.inflowFilter = same ? null : { basis: app.basis, field: app.statsGroup, value: value };
loadApplications(true);
});
$('[data-role="app-chips"]').addEventListener('click', function (e) {
var btn = closest(e.target, '[data-app-clear]');
if (!btn) return;
if (btn.getAttribute('data-app-clear') === 'group') {
state.app.group = null; state.app.groupLabel = '';
each($all('[data-app-groupkey]'), function (tr) { tr.classList.toggle('is-selected', !tr.getAttribute('data-app-groupkey')); });
} else {
state.app.inflowFilter = null;
}
loadApplications(true);
});
$('[data-role="app-rows"]').addEventListener('click', function (e) {
var row = closest(e.target, '[data-app-row]');
if (row) openApplicationDrawer(row.getAttribute('data-app-row'));
});
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 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 sumStats(partners) {
return partners.reduce(function (t, p) {
t.clickCount += p.stats.clickCount;
t.applicationCount += p.stats.applicationCount;
t.purchaseCount += p.stats.purchaseCount;
t.linkCount += p.linkCount;
return t;
}, { clickCount: 0, applicationCount: 0, purchaseCount: 0, linkCount: 0 });
}
function findPartner(id) {
return state.ptn.partners.filter(function (p) { return p.id === id; })[0] || null;
}
// 다른 화면에서 업체 링크 열기
function openPartners(partnerId, range) {
state.ptn.partnerId = partnerId || '';
state.ptn.range = range || 'all';
go('partners');
}
function loadPartnersView() {
setSeg('[data-ptn-range]', 'data-ptn-range', state.ptn.range);
loadPartners();
loadPtnProducts(false);
loadLinks(true);
loadOrderSyncStatus();
}
function loadPartners() {
return api('GET', '/admin/partners' + toQuery(Object.assign({ limit: LIST_LIMIT }, periodQuery(state.ptn.range, 'from', 'to'))))
.then(function (page) {
state.ptn.partners = page.items;
if (state.ptn.partnerId && !findPartner(state.ptn.partnerId)) state.ptn.partnerId = '';
renderPartners();
}).catch(errorNotice);
}
function renderPartners() {
var ptn = state.ptn, list = ptn.partners, t = sumStats(list);
setKpi('ptn-clicks', num(t.clickCount));
setKpi('ptn-apps', num(t.applicationCount));
setKpi('ptn-apps-sub', '클릭 대비 ' + pct(t.applicationCount, t.clickCount));
setKpi('ptn-buys', num(t.purchaseCount));
setKpi('ptn-count', num(list.length));
setKpi('ptn-count-sub', '발급한 링크 ' + num(t.linkCount) + '개');
$('[data-role="ptn-partner-total"]').textContent = list.length ? num(list.length) + '곳' : '';
var stats = function (s, links) {
return '<span class="mgm-pitem-stats">' + (links === undefined ? '' : '<span>링크 <b>' + num(links) + '</b></span>') +
'<span>클릭 <b>' + num(s.clickCount) + '</b></span><span>신청 <b>' + num(s.applicationCount) + '</b></span>' +
'<span>구매 <b>' + num(s.purchaseCount) + '</b></span></span>';
};
var html = '<button type="button" class="mgm-pitem' + (ptn.partnerId === '' ? ' is-active' : '') + '" data-ptn-partner="">' +
'<span class="mgm-pitem-name">전체 업체</span>' + stats(t) + '</button>';
html += list.map(function (p) {
var active = p.id === ptn.partnerId;
return '<div><button type="button" class="mgm-pitem' + (active ? ' is-active' : '') + '" data-ptn-partner="' + esc(p.id) + '">' +
'<span class="mgm-pitem-name">' + esc(p.name) + '</span>' +
(p.memo ? '<span class="mgm-pitem-memo" title="' + esc(p.memo) + '">' + esc(p.memo) + '</span>' : '') +
stats(p.stats, p.linkCount) + '</button>' +
(active ? '<div class="mgm-pitem-actions"><button type="button" class="mgm-link mgm-sm" data-ptn-edit-partner="' + esc(p.id) + '">이름·메모 수정</button>' +
(p.linkCount === 0 ? '<button type="button" class="mgm-link mgm-sm is-danger" data-ptn-delete-partner="' + esc(p.id) + '">삭제</button>' : '') + '</div>' : '') +
'</div>';
}).join('');
if (!list.length) {
html += '<div class="mgm-empty" style="padding:20px 8px"><b>등록된 업체가 없습니다</b>링크를 줄 업체를 먼저 추가하세요.' +
'<br><button type="button" class="mgm-primary" data-action="ptn-new-partner">+ 업체 추가</button></div>';
}
$('[data-role="ptn-partner-list"]').innerHTML = html;
var selected = findPartner(ptn.partnerId);
$('[data-role="ptn-link-title"]').textContent = selected ? selected.name + ' 링크' : '전체 링크';
}
function loadPtnProducts(force) {
if (state.ptn.products && !force) return Promise.resolve();
return api('GET', '/admin/products' + toQuery({ limit: LIST_LIMIT })).then(function (page) {
state.ptn.products = page.items;
}).catch(errorNotice);
}
function linkRowHtml(l) {
state.ptn.links[l.id] = l;
return '<tr data-link-row="' + esc(l.id) + '"' + (l.id === state.ptn.conv.linkId ? ' class="is-selected"' : '') + '>' +
'<td class="mgm-wrap"><span class="mgm-cell-main">' + esc(l.partner.name) + '</span>' +
'<span class="mgm-cell-sub">' + (l.product.name ? esc(l.product.name) : '아임웹에서 지운 상품') + ' · #' + esc(l.product.prodNo) + '</span></td>' +
'<td><div class="mgm-linkcell">' +
'<div class="mgm-linkcell-top"><span class="mgm-code">' + esc(l.shortUrl) + '</span>' +
'<button type="button" class="mgm-link mgm-sm" data-ptn-copy="' + esc(l.shortUrl) + '">복사</button></div>' +
'<span class="mgm-url" title="이동할 상품 페이지: ' + esc(l.targetUrl) + '">→ ' + esc(l.targetUrl) + '</span>' +
'</div></td>' +
'<td class="mgm-r">' + num(l.stats.clickCount) + '</td>' +
'<td class="mgm-r"><b>' + num(l.stats.applicationCount) + '</b></td>' +
'<td class="mgm-r"><b>' + num(l.stats.purchaseCount) + '</b></td>' +
'<td><button type="button" class="mgm-switch" data-ptn-toggle-link="' + esc(l.id) + '" aria-pressed="' + l.isActive + '" ' +
'aria-label="' + (l.isActive ? '사용 중 — 눌러서 중지' : '중지됨 — 눌러서 다시 사용') + '" title="' + (l.isActive ? '사용 중' : '중지됨') + '"></button></td>' +
'<td><button type="button" class="mgm-link mgm-sm" data-ptn-conv="' + esc(l.id) + '">실적</button>' +
'<button type="button" class="mgm-link mgm-sm" data-ptn-edit-link="' + esc(l.id) + '">주소 변경</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 }, periodQuery(ptn.range, 'from', 'to'));
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 markSelectedLink() {
each($all('[data-link-row]'), function (tr) {
tr.classList.toggle('is-selected', tr.getAttribute('data-link-row') === state.ptn.conv.linkId);
});
}
/* ----- 링크 실적 상세 (상세 패널) ----- */
function convRowHtml(c) {
var m = c.member;
return '<tr>' +
'<td>' + whenCell(c.convertedAt) + '</td>' +
'<td>' + (PTN_CONV_TYPE[c.type] || esc(c.type)) +
(c.isValid ? '' : ' <span class="mgm-badge is-danger" title="환불·취소되어 집계에서 빠졌습니다">환불·취소</span>') +
(c.orderNo ? '<span class="mgm-cell-sub">주문 ' + esc(c.orderNo) + '</span>' : '') + '</td>' +
'<td>' + (m ? personCell(m.name, m.uid) : personCell(c.memberCode, '회원 정보 없음')) + '</td>' +
'<td>' + dash(phone(m && m.callnum)) + '</td>' +
'<td><span class="mgm-cell-sub" style="margin:0">' + esc(kstShort(c.touchedAt)) + '</span></td>' +
'</tr>';
}
function openConversions(linkId) {
var l = state.ptn.links[linkId];
if (!l) return;
var conv = state.ptn.conv;
conv.linkId = linkId; conv.type = '';
markSelectedLink();
openDrawer(l.partner.name + ' · 실적',
'<section class="mgm-dsec"><h4>링크</h4>' + dlHtml([
['상품', (l.product.name ? esc(l.product.name) : '아임웹에서 지운 상품') + ' <span class="mgm-faint">#' + esc(l.product.prodNo) + '</span>'],
['업체에 줄 링크', '<span class="mgm-code">' + esc(l.shortUrl) + '</span>'],
['실적 (기간 내)', '클릭 ' + num(l.stats.clickCount) + ' · 신청 ' + num(l.stats.applicationCount) + ' · 구매 ' + num(l.stats.purchaseCount)]
]) + '</section>' +
'<section class="mgm-dsec"><div class="mgm-row" style="margin-bottom:10px"><h4 style="margin:0">신청·구매한 회원</h4><span class="mgm-spacer"></span>' +
'<div class="mgm-seg" role="group" aria-label="실적 종류">' +
'<button type="button" data-conv-type="" class="is-active">전체</button>' +
'<button type="button" data-conv-type="application">신청</button>' +
'<button type="button" data-conv-type="purchase">구매</button></div></div>' +
'<div class="mgm-table-wrap"><table><thead><tr><th>일시</th><th>종류</th><th>회원</th><th>연락처</th><th>링크 클릭</th></tr></thead>' +
'<tbody data-role="conv-rows"></tbody></table></div>' +
'<div class="mgm-empty" data-role="conv-empty" hidden><b>아직 실적이 없습니다</b>전체 기간 기준입니다.</div>' +
'<div class="mgm-more"><button type="button" data-conv-more hidden>더 보기</button></div></section>');
loadConversions(true);
}
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 body = $('[data-role="drawer-body"]');
var moreBtn = body.querySelector('[data-conv-more]');
if (moreBtn) moreBtn.disabled = true;
api('GET', '/admin/partner-links/' + encodeURIComponent(conv.linkId) + '/conversions' + toQuery(q)).then(function (page) {
if (seq !== conv.seq) return;
var tbody = body.querySelector('[data-role="conv-rows"]');
if (!tbody) return;
var html = page.items.map(convRowHtml).join('');
if (reset) tbody.innerHTML = html; else tbody.insertAdjacentHTML('beforeend', html);
conv.cursor = page.pageInfo.nextCursor;
if (moreBtn) moreBtn.hidden = !page.pageInfo.hasNext;
body.querySelector('[data-role="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;
if (moreBtn) moreBtn.disabled = false;
});
}
drawer.addEventListener('click', function (e) {
var t = closest(e.target, 'button');
if (!t) return;
if (t.hasAttribute('data-conv-type')) {
state.ptn.conv.type = t.getAttribute('data-conv-type');
each(drawer.querySelectorAll('[data-conv-type]'), function (x) { x.classList.toggle('is-active', x === t); });
loadConversions(true);
} else if (t.hasAttribute('data-conv-more')) {
loadConversions(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
? '구매 집계 ' + kstShort(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) notify('구매 집계를 마쳤습니다. 새로 잡힌 구매 ' + num(s.createdCount) + '건');
if (state.view === 'partners') { loadPartners(); loadLinks(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; });
}
function copyWithToast(url) {
copyText(url).then(function (ok) {
if (ok) notify('링크를 복사했습니다<br><span class="mgm-code">' + esc(url) + '</span>');
else toast('복사하지 못했습니다. 직접 선택해 복사해 주세요<br><span class="mgm-code">' + esc(url) + '</span>', 'error');
});
}
/* ----- 대화상자: 업체·링크 ----- */
function partnerFields(p) {
return '<label class="mgm-field">업체 이름<input name="partnerName" maxlength="100" required value="' + esc(p ? p.name : '') + '" placeholder="예: 머니튜브"></label>' +
'<label class="mgm-field">메모 <span class="mgm-hint">선택 — 담당자·정산 조건 등</span><input name="memo" maxlength="1000" value="' + esc(p && p.memo ? p.memo : '') + '"></label>';
}
function newPartnerDialog() {
return openModal({
title: '업체 추가', html: partnerFields(null), confirmText: '추가',
submit: function (v) {
if (!v.partnerName.trim()) return Promise.reject('업체 이름을 입력해 주세요.');
var body = { name: v.partnerName.trim() };
if (v.memo.trim()) body.memo = v.memo.trim();
return api('POST', '/admin/partners', body);
}
}).then(function (p) {
if (!p) return;
notify('업체 "' + esc(p.name) + '"을(를) 추가했습니다.');
state.ptn.partnerId = p.id;
loadPartners();
loadLinks(true);
});
}
function editPartnerDialog(id) {
var p = findPartner(id);
if (!p) return;
openModal({
title: '업체 수정', html: partnerFields(p), confirmText: '저장',
submit: function (v) {
if (!v.partnerName.trim()) return Promise.reject('업체 이름을 입력해 주세요.');
return api('PATCH', '/admin/partners/' + encodeURIComponent(id), { name: v.partnerName.trim(), memo: v.memo.trim() });
}
}).then(function (r) {
if (!r) return;
notify('업체 정보를 저장했습니다.');
loadPartners();
loadLinks(true);
});
}
function deletePartnerDialog(id) {
var p = findPartner(id);
if (!p) return;
confirmModal('업체 삭제', '업체 "' + esc(p.name) + '"을(를) 삭제할까요?', '삭제', true).then(function (ok) {
if (!ok) return;
api('DELETE', '/admin/partners/' + encodeURIComponent(id)).then(function () {
notify('업체를 삭제했습니다.');
if (state.ptn.partnerId === id) state.ptn.partnerId = '';
loadPartners();
loadLinks(true);
}).catch(errorNotice);
});
}
function newLinkDialog() {
var ptn = state.ptn;
if (!ptn.partners.length) { newPartnerDialog(); return; }
loadPtnProducts(false).then(function () {
var products = ptn.products || [];
var partnerOptions = ptn.partners.map(function (p) {
return '<option value="' + esc(p.id) + '"' + (p.id === ptn.partnerId ? ' selected' : '') + '>' + esc(p.name) + '</option>';
}).join('');
var productOptions = products.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('');
openModal({
title: '링크 발급',
confirmText: '발급',
html:
'<label class="mgm-field">업체<select name="partnerId" required><option value="">업체 선택</option>' + partnerOptions + '</select></label>' +
'<label class="mgm-field">상품' + (products.length
? '<select name="prodNo" required><option value="">상품 선택</option>' + productOptions + '</select>'
: '<span class="mgm-hint" style="color:var(--mgm-warn)">불러온 상품이 없습니다. 툴바의 \'상품 불러오기\'를 먼저 눌러 주세요.</span><input type="hidden" name="prodNo" value="">') + '</label>' +
'<label class="mgm-field">이동할 상품 페이지 주소<input name="targetUrl" type="url" maxlength="2000" required placeholder="https://www.moneyguider.co.kr/free-shop/?idx=52">' +
'<span class="mgm-hint">아임웹에서 상품 페이지를 열고 주소창의 주소를 붙여 넣으세요. 우리 사이트 안의 주소만 됩니다.</span></label>' +
'<p class="mgm-hint">업체·상품 조합당 링크는 하나입니다. 발급 뒤에도 이동 주소는 바꿀 수 있습니다.</p>',
submit: function (v) {
if (!v.partnerId) return Promise.reject('업체를 선택해 주세요.');
if (!v.prodNo) return Promise.reject('상품을 선택해 주세요.');
if (!v.targetUrl.trim()) return Promise.reject('이동할 상품 페이지 주소를 입력해 주세요.');
return api('POST', '/admin/partner-links', { partnerId: v.partnerId, prodNo: Number(v.prodNo), targetUrl: v.targetUrl.trim() });
}
}).then(function (l) {
if (!l) return;
loadPartners();
loadLinks(true);
showIssuedLink(l);
});
});
}
function showIssuedLink(l) {
openModal({
title: '링크를 발급했습니다',
hideCancel: true,
confirmText: '닫기',
html: '<p>' + esc(l.partner.name) + ' · ' + esc(l.product.name || '#' + l.product.prodNo) + '</p>' +
'<div class="mgm-linkbox"><input readonly value="' + esc(l.shortUrl) + '" aria-label="업체에 줄 링크">' +
'<button type="button" class="mgm-primary" data-modal-copy="' + esc(l.shortUrl) + '">복사</button></div>' +
'<p class="mgm-hint">이 주소를 업체에 전달하세요. 누르면 상품 페이지로 이동하며 클릭·신청·구매가 이 업체 실적으로 잡힙니다.</p>'
});
}
modal.el.addEventListener('click', function (e) {
var btn = closest(e.target, '[data-modal-copy]');
if (btn) copyWithToast(btn.getAttribute('data-modal-copy'));
});
function editLinkDialog(id) {
var l = state.ptn.links[id];
if (!l) return;
openModal({
title: '이동 주소 변경',
confirmText: '저장',
html: '<p class="mgm-muted">' + esc(l.partner.name) + ' · ' + esc(l.product.name || '#' + l.product.prodNo) + '</p>' +
'<label class="mgm-field">이동할 상품 페이지 주소<input name="targetUrl" type="url" maxlength="2000" required value="' + esc(l.targetUrl) + '"></label>' +
'<p class="mgm-hint">업체에 준 링크는 그대로 쓰면 됩니다. 앞으로 누르는 사람만 새 주소로 이동합니다.</p>',
submit: function (v) {
if (!v.targetUrl.trim()) return Promise.reject('이동할 주소를 입력해 주세요.');
return api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { targetUrl: v.targetUrl.trim() });
}
}).then(function (r) {
if (!r) return;
notify('이동 주소를 바꿨습니다.');
loadLinks(true);
});
}
function toggleLink(id) {
var l = state.ptn.links[id];
if (!l) return;
var apply = function () {
api('PATCH', '/admin/partner-links/' + encodeURIComponent(id), { isActive: !l.isActive }).then(function () {
notify(l.isActive ? '링크를 중지했습니다.' : '링크를 다시 사용합니다.');
loadLinks(true);
}).catch(errorNotice);
};
if (!l.isActive) { apply(); return; }
confirmModal('링크 중지', '이 링크를 중지할까요?<br><span class="mgm-muted">중지해도 상품 페이지로 이동은 되지만, 클릭·실적은 더 이상 쌓이지 않습니다.</span>', '중지', true)
.then(function (ok) { if (ok) apply(); });
}
each($all('[data-ptn-range]'), function (b) {
b.addEventListener('click', function () {
state.ptn.range = b.getAttribute('data-ptn-range');
setSeg('[data-ptn-range]', 'data-ptn-range', state.ptn.range);
loadPartners();
loadLinks(true);
});
});
$('[data-panel="partners"]').addEventListener('click', function (e) {
var t = closest(e.target, 'button');
if (!t || !$('[data-panel="partners"]').contains(t)) return;
var action = t.getAttribute('data-action'), id;
if (action === 'ptn-product-sync') {
t.disabled = true;
api('POST', '/admin/products/sync-runs').then(function (r) {
notify('아임웹 상품 ' + num(r.fetchedCount) + '개를 불러왔습니다.');
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-new-link') {
newLinkDialog();
} else if (action === 'ptn-new-partner') {
newPartnerDialog();
} else if (action === 'ptn-link-more') {
loadLinks(false);
} else if (t.hasAttribute('data-ptn-partner')) {
state.ptn.partnerId = t.getAttribute('data-ptn-partner');
renderPartners();
loadLinks(true);
} else if ((id = t.getAttribute('data-ptn-edit-partner'))) {
editPartnerDialog(id);
} else if ((id = t.getAttribute('data-ptn-delete-partner'))) {
deletePartnerDialog(id);
} else if (t.hasAttribute('data-ptn-copy')) {
copyWithToast(t.getAttribute('data-ptn-copy'));
} else if ((id = t.getAttribute('data-ptn-conv'))) {
openConversions(id);
} else if ((id = t.getAttribute('data-ptn-edit-link'))) {
editLinkDialog(id);
} else if ((id = t.getAttribute('data-ptn-toggle-link'))) {
toggleLink(id);
}
});
/* ===================== 회원 목록 필터 ===================== */
// 버튼 없이 바로 적용한다 — 선택형은 바꾸는 즉시, 검색어는 입력을 멈추면
var FILTER_KEYS = ['inflowStatus', 'source', 'medium', 'campaign', 'content', 'q'];
var ADVANCED_KEYS = ['inflowStatus', 'source', 'medium', 'campaign', 'content'];
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 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;
});
setSeg('[data-range]', 'data-range', match);
}
function advancedCount() {
return ADVANCED_KEYS.filter(function (k) { return filterForm[k].value.trim(); }).length;
}
function toggleAdvanced(open) {
var panel = $('[data-role="filter-advanced"]');
var next = open === undefined ? panel.hidden : open;
panel.hidden = !next;
$('[data-action="toggle-filters"]').setAttribute('aria-expanded', String(next));
}
// 적용 중인 필터를 칩으로 보여주고 × 로 하나씩 지울 수 있게 한다
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-wrap"]').hidden = chips.length === 0;
var n = advancedCount();
$('[data-role="filter-count"]').innerHTML = n ? '<span class="mgm-badge is-accent">' + n + '</span>' : '';
}
function applyFilters() { loadMembers(true); }
// 마지막으로 적용한 드롭다운 값 — 같은 값으로 change 가 또 와도 다시 조회하지 않는다
function rememberApplied() {
SUGGEST_FIELDS.forEach(function (k) { state.appliedFilters[k] = filterForm[k].value.trim(); });
}
function resetFilters() {
filterForm.reset();
state.groupCode = '';
setSeg('[data-range]', 'data-range', '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-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 = closest(e.target, '[data-group-code]');
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;
setSeg('[data-range]', 'data-range', 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 = closest(e.target, '[data-clear]');
if (!btn) return;
var key = btn.getAttribute('data-clear');
if (key === 'period') { filterForm.from.value = ''; filterForm.to.value = ''; setSeg('[data-range]', 'data-range', '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(); e.stopPropagation(); }
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 = closest(e.target, '.mgm-opt');
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';
setSeg('[data-range]', 'data-range', range);
toggleAdvanced(true);
go('members');
}
/* ===================== 회원 목록 ===================== */
function rowHtml(m) {
var inf = m.inflow;
state.memberRows[m.memberCode] = m;
return '<tr class="is-clickable" data-member-row="' + esc(m.memberCode) + '">' +
'<td>' + whenCell(m.joinTime) + '</td>' +
'<td>' + personCell(m.name, m.uid) + '</td>' +
'<td>' + dash(phone(m.callnum)) + '</td>' +
'<td>' + (m.socialProvider ? esc(SOCIAL_LABEL[m.socialProvider] || m.socialProvider) : '일반') + '</td>' +
'<td class="mgm-wrap" style="min-width:160px;max-width:300px">' + groupsHtml(m) + '</td>' +
'<td class="mgm-wrap"><div class="mgm-row" style="gap:6px;flex-wrap:nowrap;align-items:flex-start">' + (STATUS_BADGE[inf.status] || '') +
'<div>' + (inf.status === 'none' ? '' : inflowText(inf)) + '</div></div></td>' +
'<td>' + dash(inf.landingPath) + (inf.productId ? ' <span class="mgm-faint">#' + esc(inf.productId) + '</span>' : '') + '</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();
if (advancedCount()) toggleAdvanced(true);
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) + '명' + (page.pageInfo.hasNext ? ' 중 ' + 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;
});
}
$('[data-role="rows"]').addEventListener('click', function (e) {
var row = closest(e.target, '[data-member-row]');
if (!row) return;
var m = state.memberRows[row.getAttribute('data-member-row')];
if (m) openDrawer(m.name + ' 님', memberSectionsHtml(m));
});
/* ===================== 시작 ===================== */
// 아임웹 연결을 마치고 돌아온 경우 결과를 보여주고 주소창을 정리한다
(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') notify('아임웹이 연결되었습니다. 첫 동기화를 시작합니다.');
else {
var imwebCode = params.get('imwebCode');
var detail = imwebCode
? '아임웹 오류 ' + esc(imwebCode) + (IMWEB_HINT[imwebCode] ? ' — ' + esc(IMWEB_HINT[imwebCode]) : '')
: esc(params.get('code') || '알 수 없음');
toast('아임웹 연결에 실패했습니다. (' + 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 은 세션을 지운다) 새로고침으로 다시 시도할 수 있게 한다
var loginNotice = $('[data-role="login-notice"]');
$('[data-role="login"]').hidden = false;
loginNotice.innerHTML = errorText(err) + ' 잠시 후 새로고침해 주세요.';
loginNotice.hidden = false;
});
})();
</script>
</div>