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