generated from ttipo/project-base
5badf94dc7
- Header Code '구매 때 유입 전송': /shop_payment_complete 에서 order_no·유입·업체 클릭 ID 를 POST /api/order-inflows 로 한 번 보낸다 (같은 탭 새로고침 때는 다시 보내지 않는다) - 결제 페이지(/shop_payment*)에서는 리퍼러를 유입으로 기록하지 않는다 (v1.3) — 결제창에서 돌아올 때 pay.kakao.com 이 '카카오 소셜', pay.naver.com 이 '네이버 검색' 유입으로 잡혀 구매 때 유입을 가릴 수 있었다 - 관리자 화면: 유입 기준을 '신청·구매 때 유입'으로, 구매 행·상세 패널에 구매 때 유입, 업체 실적 상세의 비회원 구매는 주문자 이름으로 보여준다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2731 lines
153 KiB
HTML
2731 lines
153 KiB
HTML
<!-- ============================================================
|
||
[자본주의가이드] 관리자 대시보드 — 신청·업체 링크·회원 유입경로
|
||
설치 위치: 아임웹 관리자 전용 페이지 > 코드 위젯 (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="7" data-app-kind="application">
|
||
<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"> </span>
|
||
</button>
|
||
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7" data-app-kind="purchase">
|
||
<span class="mgm-kpi-label">최근 7일 유료 구매</span>
|
||
<span class="mgm-kpi-value" data-kpi="buy-week">-</span>
|
||
<span class="mgm-kpi-sub" data-kpi="buy-week-sub"> </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"> </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"> </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>
|
||
<div class="mgm-seg" role="group" aria-label="종류">
|
||
<button type="button" data-app-type="all" class="is-active">전체</button>
|
||
<button type="button" data-app-type="application">무료 신청</button>
|
||
<button type="button" data-app-type="purchase">유료 구매</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><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" 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>업체</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>기간이나 종류, 선택한 항목을 바꿔 보세요. 구매는 아임웹 주문을 1시간마다 가져옵니다.</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"> </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"> </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, type: all | application | purchase,
|
||
// group: 고른 상품번호 / 업체 ID('none' = 업체 없음)
|
||
app: {
|
||
range: 'all', mode: 'product', type: 'all', group: null, groupLabel: '',
|
||
cursor: null, loading: false, seq: 0, groupsSeq: 0,
|
||
basis: 'apply', 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, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
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'), type: link.getAttribute('data-app-kind') || 'all' });
|
||
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();
|
||
|
||
// 오늘·최근 7일 신청·구매 수 — 두 요청이 모두 와야 카드 두 장을 함께 채운다
|
||
Promise.all([
|
||
api('GET', '/admin/activities/groups' + toQuery(periodQuery('today', 'from', 'to'))),
|
||
api('GET', '/admin/activities/groups' + toQuery(periodQuery('7', 'from', 'to')))
|
||
]).then(function (res) {
|
||
var today = res[0].totals, week = res[1].totals;
|
||
setKpi('app-week', num(week.applicationCount));
|
||
setKpi('app-week-sub', '오늘 ' + num(today.applicationCount) + '건 · 카톡 버튼 클릭률 ' + pct(week.kakaoCount, week.applicationCount));
|
||
setKpi('buy-week', num(week.purchaseCount));
|
||
setKpi('buy-week-sub', '오늘 ' + num(today.purchaseCount) + '건' + (week.cancelledCount ? ' · 환불·취소 ' + num(week.cancelledCount) + '건 별도' : ' · 환불·취소 제외'));
|
||
}).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/activities/inflow-stats' + toQuery(Object.assign({ basis: 'apply', groupBy: 'source' }, periodQuery('30', 'from', 'to')))).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/activities' + 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 inf = a.inflow;
|
||
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
|
||
'<td>' + whenCell(a.occurredAt) + '</td>' +
|
||
'<td>' + personCell(activityName(a), '') + '</td>' +
|
||
'<td class="mgm-wrap">' + typeBadge(a) + ' ' + 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', type: 'all', inflowFilter: { basis: 'apply', 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: '신청·구매 때 유입', join: '가입 경로' };
|
||
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
|
||
var APP_TYPE_LABEL = { application: '무료 신청', purchase: '유료 구매' };
|
||
|
||
// 구분 칸 — 무료 신청 / 유료 구매 (+ 환불·취소)
|
||
function typeBadge(a) {
|
||
if (a.type === 'application') return '<span class="mgm-badge is-ok">무료 신청</span>';
|
||
return '<span class="mgm-badge is-accent">유료 구매</span>' +
|
||
(a.isCancelled ? ' <span class="mgm-badge is-danger" title="환불·취소·반품된 주문 — 구매 수에서 빠집니다">환불·취소</span>' : '');
|
||
}
|
||
// 누구인지 — 회원이면 회원 이름, 비회원 구매면 주문자 이름
|
||
function activityName(a) {
|
||
if (a.member) return a.member.name;
|
||
if (a.orderer && a.orderer.name) return a.orderer.name;
|
||
return a.memberUid || '';
|
||
}
|
||
function activityPersonCell(a) {
|
||
if (a.member) return personCell(a.member.name, a.member.uid);
|
||
if (!a.memberUid && a.orderer) return personCell(a.orderer.name, '비회원');
|
||
return personCell(a.memberUid, '회원 정보 없음');
|
||
}
|
||
function activityPhone(a) {
|
||
if (a.member && a.member.callnum) return a.member.callnum;
|
||
return a.orderer ? a.orderer.phone : '';
|
||
}
|
||
|
||
// 다른 화면에서 신청·구매 내역 열기 — 기간·종류·유입 필터를 미리 채운다
|
||
function openApplications(opts) {
|
||
var app = state.app;
|
||
app.range = opts.range || 'all';
|
||
app.type = opts.type || 'all';
|
||
app.group = null; app.groupLabel = '';
|
||
app.inflowFilter = opts.inflowFilter || null;
|
||
if (app.inflowFilter) app.basis = app.inflowFilter.basis;
|
||
go('applications');
|
||
}
|
||
|
||
function loadApplicationsView() {
|
||
setSeg('[data-app-mode]', 'data-app-mode', state.app.mode);
|
||
setSeg('[data-app-range]', 'data-app-range', state.app.range);
|
||
setSeg('[data-app-type]', 'data-app-type', state.app.type);
|
||
loadAppGroups();
|
||
loadApplications(true);
|
||
}
|
||
|
||
// 목록·집계 공통 — 기간 + 종류 + 고른 상품/업체
|
||
function appScope() {
|
||
var app = state.app, q = periodQuery(app.range, 'from', 'to');
|
||
if (app.type !== 'all') q.type = app.type;
|
||
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/activities/groups' + toQuery(Object.assign({ by: app.mode }, periodQuery(app.range, 'from', 'to')))).then(function (g) {
|
||
if (seq !== app.groupsSeq) return;
|
||
var rows = [Object.assign({ group: null, label: isPartner ? '전체' : '전체 상품', isAll: true }, g.totals)]
|
||
.concat(g.items.map(function (i) {
|
||
// 화면용 값이 API 값(label 등)을 덮어쓰도록 뒤에 둔다
|
||
return Object.assign({}, i, {
|
||
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 ? '직접·광고 등 업체 링크를 거치지 않음' : ''),
|
||
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">' + countCell(r.applicationCount) + '</td>' +
|
||
'<td class="mgm-r">' + (r.applicationCount ? num(r.kakaoCount) + '<span class="mgm-rate">' + pct(r.kakaoCount, r.applicationCount) + '</span>' : '<span class="mgm-faint">-</span>') + '</td>' +
|
||
'<td class="mgm-r">' + countCell(r.purchaseCount) +
|
||
(r.cancelledCount ? '<span class="mgm-rate" title="환불·취소된 구매">환불 ' + num(r.cancelledCount) + '</span>' : '') + '</td></tr>';
|
||
}).join('');
|
||
}).catch(function (err) { if (seq === app.groupsSeq) errorNotice(err); });
|
||
}
|
||
function countCell(n) { return n ? '<b>' + num(n) + '</b>' : '<span class="mgm-faint">0</span>'; }
|
||
|
||
// 유입 집계 기준 버튼 — 신청·구매 모두 두 기준으로 볼 수 있다
|
||
function syncBasisButtons() {
|
||
setSeg('[data-app-basis]', 'data-app-basis', state.app.basis);
|
||
}
|
||
|
||
function loadAppStats() {
|
||
var app = state.app;
|
||
var seq = ++app.statsSeq;
|
||
syncBasisButtons();
|
||
var q = Object.assign({ basis: app.basis, groupBy: app.statsGroup }, appScope());
|
||
api('GET', '/admin/activities/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, '건');
|
||
var counted = [];
|
||
if (app.type !== 'purchase') counted.push('무료 신청 ' + num(s.applicationTotal) + '건');
|
||
if (app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건');
|
||
$('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + 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 isApp = a.type === 'application';
|
||
state.app.rows[a.id] = a;
|
||
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
|
||
'<td>' + whenCell(a.occurredAt) + '</td>' +
|
||
'<td>' + typeBadge(a) + '</td>' +
|
||
'<td>' + activityPersonCell(a) + '</td>' +
|
||
'<td>' + dash(phone(activityPhone(a))) + '</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>' + (isApp && 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">' + (!a.member && !a.memberUid ? '<span class="mgm-faint">비회원</span>' : joinInflowCell(a.member)) + '</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/activities' + 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 partnerText(a) {
|
||
return a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span> 업체 링크 실적' : '업체 링크를 거치지 않음';
|
||
}
|
||
|
||
function openApplicationDrawer(id) {
|
||
var a = state.app.rows[id];
|
||
if (!a) return;
|
||
var html;
|
||
if (a.type === 'application') {
|
||
var inf = a.inflow || {};
|
||
html = '<section class="mgm-dsec"><h4>무료 신청</h4>' + dlHtml([
|
||
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
|
||
['신청일시', esc(kstDateTime(a.occurredAt))],
|
||
['업체', partnerText(a)],
|
||
['카톡 버튼', 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);
|
||
} else {
|
||
var o = a.orderer || {};
|
||
var isGuest = !a.member && !a.memberUid;
|
||
html = '<section class="mgm-dsec"><h4>유료 구매' + (a.isCancelled ? ' <span class="mgm-badge is-danger">환불·취소</span>' : '') + '</h4>' + dlHtml([
|
||
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
|
||
['주문일시', esc(kstDateTime(a.occurredAt))],
|
||
['주문번호', esc(a.orderNo)],
|
||
['상태', a.isCancelled ? '환불·취소됨 <span class="mgm-faint">(구매 수에서 빠집니다)</span>' : '결제완료'],
|
||
['업체', partnerText(a)]
|
||
]) + '</section>' +
|
||
'<section class="mgm-dsec"><h4>주문자</h4>' + dlHtml([
|
||
['이름', dash(o.name)],
|
||
['이메일', dash(o.email)],
|
||
['연락처', dash(phone(o.phone))],
|
||
['회원 여부', isGuest ? '비회원 주문' : '회원 주문']
|
||
]) + '</section>' +
|
||
'<section class="mgm-dsec"><h4>구매 때 유입</h4>' + (a.inflow && a.inflow.source ? dlHtml([
|
||
['유입원', esc(a.inflow.source)],
|
||
['매체', esc(a.inflow.medium)],
|
||
['캠페인', esc(a.inflow.campaign)],
|
||
['콘텐츠', esc(a.inflow.content)]
|
||
]) : '<p class="mgm-muted">기록 없음 — 주문완료 화면 연동 전 주문이거나, 다른 기기·브라우저에서 결제한 경우입니다.</p>') + '</section>' +
|
||
(isGuest ? '' : memberSectionsHtml(a.member));
|
||
}
|
||
openDrawer((activityName(a) || '알 수 없음') + ' 님의 ' + APP_TYPE_LABEL[a.type], 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-type]'), function (b) {
|
||
b.addEventListener('click', function () {
|
||
var type = b.getAttribute('data-app-type');
|
||
if (type === state.app.type) return;
|
||
state.app.type = type;
|
||
setSeg('[data-app-type]', 'data-app-type', type);
|
||
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 () {
|
||
if (b.disabled) return;
|
||
state.app.basis = b.getAttribute('data-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)
|
||
: c.memberCode ? personCell(c.memberCode, '회원 정보 없음')
|
||
: personCell(c.ordererName, '비회원 구매')) + '</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>
|