generated from ttipo/project-base
feat(imweb): 업체 계정 로그인 화면과 최고관리자 계정 관리
- 업체 계정: '내 업체 실적' 메뉴만 보인다 (기간별 클릭·신청·구매, 나눠 줄 링크, 가린 이름의 신청·구매 목록). 임시 비밀번호면 닫을 수 없는 비밀번호 변경 창을 먼저 띄운다 (대신 로그아웃 가능) - 최고관리자: 업체 링크 → 업체 선택 → '계정 관리'에서 만들기·사용/중지·비밀번호 초기화. 임시 비밀번호는 한 번만 보여주고 바깥 클릭·Esc 로 닫히지 않는다. 복사해도 알림에 비밀번호를 띄우지 않는다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+428
-21
@@ -245,7 +245,13 @@
|
||||
#mg-members .mgm-pitem-memo { display: block; font-size: 12px; color: var(--mgm-muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
#mg-members .mgm-pitem-stats { display: flex; gap: 10px; margin-top: 6px; font-size: 12px; color: var(--mgm-muted); font-variant-numeric: tabular-nums; }
|
||||
#mg-members .mgm-pitem-stats b { color: var(--mgm-text); font-weight: 600; }
|
||||
#mg-members .mgm-pitem-actions { display: flex; gap: 2px; margin: 2px 0 2px 4px; }
|
||||
#mg-members .mgm-pitem-actions { display: flex; gap: 2px; margin: 2px 0 2px 4px; flex-wrap: wrap; }
|
||||
/* 임시 비밀번호 — 한 번만 보여주므로 크게, 한 번에 선택되게 */
|
||||
#mg-members .mgm-secret {
|
||||
margin: 4px 0; padding: 14px; border-radius: 10px; background: var(--mgm-accent-soft); color: var(--mgm-text);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 20px; font-weight: 700;
|
||||
letter-spacing: .08em; text-align: center; user-select: all; word-break: break-all;
|
||||
}
|
||||
|
||||
/* 업체 링크 칸 */
|
||||
#mg-members .mgm-code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; }
|
||||
@@ -410,7 +416,7 @@
|
||||
<div class="mgm-shell" data-role="app" hidden>
|
||||
<aside class="mgm-side">
|
||||
<div class="mgm-brand">
|
||||
<div><strong>자본주의가이드</strong><span>관리자 대시보드</span></div>
|
||||
<div><strong>자본주의가이드</strong><span data-role="brand-sub">관리자 대시보드</span></div>
|
||||
</div>
|
||||
<!-- 위젯 위에 사이트 헤더가 있어도 항상 보이도록 위쪽에 둔다 -->
|
||||
<div class="mgm-account">
|
||||
@@ -418,25 +424,35 @@
|
||||
<button type="button" data-action="logout">로그아웃</button>
|
||||
</div>
|
||||
<nav class="mgm-nav" aria-label="관리 메뉴">
|
||||
<button type="button" data-view="overview">
|
||||
<!-- 업체 계정 — 이 메뉴만 보인다 -->
|
||||
<button type="button" data-view="portal" data-role-only="partner" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M3 3v18h18"/><path d="M7 15l4-4 3 3 5-6"/></svg>
|
||||
내 업체 실적
|
||||
</button>
|
||||
<button type="button" data-action="change-password" data-role-only="partner" hidden>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 0 1 8 0v4"/></svg>
|
||||
비밀번호 변경
|
||||
</button>
|
||||
<!-- 최고관리자 -->
|
||||
<button type="button" data-view="overview" data-role-only="super">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
|
||||
개요
|
||||
</button>
|
||||
<span class="mgm-nav-label">운영</span>
|
||||
<button type="button" data-view="applications">
|
||||
<span class="mgm-nav-label" data-role-only="super">운영</span>
|
||||
<button type="button" data-view="applications" data-role-only="super">
|
||||
<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">
|
||||
<button type="button" data-view="partners" data-role-only="super">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
업체 링크
|
||||
</button>
|
||||
<button type="button" data-view="members">
|
||||
<button type="button" data-view="members" data-role-only="super">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
회원
|
||||
</button>
|
||||
<span class="mgm-nav-label">설정</span>
|
||||
<button type="button" data-view="integration">
|
||||
<span class="mgm-nav-label" data-role-only="super">설정</span>
|
||||
<button type="button" data-view="integration" data-role-only="super">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
연동 관리
|
||||
</button>
|
||||
@@ -450,7 +466,7 @@
|
||||
<p class="mgm-muted" data-role="view-desc"></p>
|
||||
</div>
|
||||
<span class="mgm-spacer"></span>
|
||||
<button type="button" class="mgm-sync-pill" data-role="sync-pill" data-goto="integration" title="연동 관리로 이동">상태 확인 중</button>
|
||||
<button type="button" class="mgm-sync-pill" data-role="sync-pill" data-role-only="super" data-goto="integration" title="연동 관리로 이동">상태 확인 중</button>
|
||||
</header>
|
||||
|
||||
<!-- ========== 개요 ========== -->
|
||||
@@ -613,6 +629,81 @@
|
||||
</section>
|
||||
|
||||
<!-- ========== 업체 링크 ========== -->
|
||||
<!-- ========== 내 업체 실적 (업체 계정) ========== -->
|
||||
<section data-panel="portal" class="mgm-stack" hidden>
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-toolbar">
|
||||
<div class="mgm-seg" role="group" aria-label="실적 집계 기간">
|
||||
<button type="button" data-portal-range="all">전체</button>
|
||||
<button type="button" data-portal-range="today">오늘</button>
|
||||
<button type="button" data-portal-range="7">7일</button>
|
||||
<button type="button" data-portal-range="30" class="is-active">30일</button>
|
||||
<button type="button" data-portal-range="month">이번 달</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="mgm-kpis is-compact">
|
||||
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">링크 클릭</span><span class="mgm-kpi-value" data-kpi="portal-clicks">-</span><span class="mgm-kpi-sub">사람이 누른 것만 (미리보기·봇 제외)</span></div>
|
||||
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">신청</span><span class="mgm-kpi-value" data-kpi="portal-apps">-</span><span class="mgm-kpi-sub" data-kpi="portal-apps-sub"> </span></div>
|
||||
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">구매</span><span class="mgm-kpi-value" data-kpi="portal-buys">-</span><span class="mgm-kpi-sub">결제완료 (환불·취소 제외)</span></div>
|
||||
<div class="mgm-card mgm-kpi"><span class="mgm-kpi-label">링크</span><span class="mgm-kpi-value" data-kpi="portal-links">-</span><span class="mgm-kpi-sub">받은 링크 수</span></div>
|
||||
</div>
|
||||
|
||||
<details class="mgm-help">
|
||||
<summary>실적은 어떻게 집계되나요?</summary>
|
||||
<ul>
|
||||
<li>링크를 누른 뒤 <b>30일 안에</b> 같은 상품을 신청·구매하면 실적으로 잡힙니다. 여러 업체 링크를 눌렀다면 <b>마지막으로 누른</b> 링크의 실적입니다.</li>
|
||||
<li>구매는 결제완료 주문만 셉니다. 환불·취소되면 빠집니다. 구매는 1시간마다 확인합니다.</li>
|
||||
<li>링크를 누른 기기와 신청·구매한 기기가 다르면 잡히지 않습니다.</li>
|
||||
<li>신청·구매한 분의 이름은 일부만 보이고, 연락처·이메일은 제공하지 않습니다.</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>내 링크</h3>
|
||||
<span class="mgm-muted" data-role="portal-link-total"></span>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>상품</th><th>나눠 줄 링크</th>
|
||||
<th class="mgm-r">클릭</th><th class="mgm-r">신청</th><th class="mgm-r">구매</th><th>상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="portal-link-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mgm-empty" data-role="portal-link-empty" hidden>
|
||||
<b>받은 링크가 없습니다</b>운영사에 링크 발급을 요청해 주세요.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청·구매</h3>
|
||||
<div class="mgm-seg" role="group" aria-label="종류">
|
||||
<button type="button" data-portal-type="" class="is-active">전체</button>
|
||||
<button type="button" data-portal-type="application">신청</button>
|
||||
<button type="button" data-portal-type="purchase">구매</button>
|
||||
</div>
|
||||
<span class="mgm-spacer"></span>
|
||||
<span class="mgm-muted" data-role="portal-conv-total"></span>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>일시</th><th>구분</th><th>이름</th><th>상품</th><th>상태</th></tr>
|
||||
</thead>
|
||||
<tbody data-role="portal-conv-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mgm-more"><button type="button" data-action="portal-conv-more" hidden>더 보기</button></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section data-panel="partners" class="mgm-stack" hidden>
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-toolbar">
|
||||
@@ -645,7 +736,8 @@
|
||||
<li>링크를 누른 뒤 <b>30일 안에</b> 신청·구매하면, 그 상품의 링크 중 <b>마지막으로 누른</b> 업체 실적으로 잡습니다.</li>
|
||||
<li>구매는 결제완료 주문만 셉니다. 환불·취소되면 빠집니다. 아임웹 주문은 1시간마다 확인합니다.</li>
|
||||
<li>링크로 들어온 방문에는 업체 UTM(유입원=업체 이름, 매체=partner, 캠페인=링크 코드)이 붙어 신청 유입·가입 경로에도 업체가 나옵니다.</li>
|
||||
<li>링크를 누른 기기와 신청·구매한 기기가 다르거나, 비회원 주문이면 추적되지 않습니다.</li>
|
||||
<li>링크를 누른 기기와 신청·구매한 기기가 다르면 추적되지 않습니다. 비회원 주문은 주문완료 화면에서 링크 클릭이 확인될 때만 잡힙니다.</li>
|
||||
<li>업체를 고르고 <b>계정 관리</b>에서 업체용 로그인 계정을 만들면, 업체가 자기 링크·실적을 직접 볼 수 있습니다 (이름은 일부만, 연락처는 보이지 않음).</li>
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
@@ -876,6 +968,7 @@
|
||||
var KST_OFFSET_MS = 9 * 3600000;
|
||||
var DAY_MS = 86400000;
|
||||
var SESSION_KEY = 'mg_admin_session';
|
||||
var PASSWORD_MIN_LENGTH = 10; // 백엔드 ACCOUNT_PASSWORD_MIN_LENGTH 와 같아야 한다
|
||||
var VIEW_KEY = 'mg_admin_view';
|
||||
|
||||
var VIEWS = {
|
||||
@@ -883,8 +976,15 @@
|
||||
applications: { title: '신청·구매 내역', desc: '무료 신청과 유료 구매를 상품별·업체별로 보고, 누가 신청·구매했는지 확인합니다' },
|
||||
partners: { title: '업체 링크', desc: '업체별 링크를 발급하고 클릭·신청·구매 실적을 봅니다' },
|
||||
members: { title: '회원', desc: '가입 기간·회원 그룹·유입경로로 회원을 찾습니다' },
|
||||
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' }
|
||||
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' },
|
||||
portal: { title: '내 업체 실적', desc: '우리 업체 링크의 클릭·신청·구매 실적을 봅니다' }
|
||||
};
|
||||
// 역할마다 볼 수 있는 화면 — 업체 계정은 업체 화면만 (서버도 같은 규칙으로 막는다)
|
||||
var ROLE_HOME = { super: 'overview', partner: 'portal' };
|
||||
function viewAllowed(view) {
|
||||
if (!VIEWS[view] || !state.admin) return false;
|
||||
return (view === 'portal') === (state.admin.role === 'partner');
|
||||
}
|
||||
|
||||
var root = document.getElementById('mg-members');
|
||||
function $(sel) { return root.querySelector(sel); }
|
||||
@@ -894,6 +994,9 @@
|
||||
|
||||
var state = {
|
||||
admin: null,
|
||||
// 업체 화면 — range: 기간, type: '' | application | purchase
|
||||
portal: { range: '30', type: '', cursor: null, seq: 0, loading: false },
|
||||
credentialText: '', // 임시 비밀번호 창의 복사용 문구 (창을 닫으면 지운다)
|
||||
view: 'overview',
|
||||
status: null,
|
||||
pollTimer: null,
|
||||
@@ -1072,8 +1175,14 @@
|
||||
box.hidden = false;
|
||||
});
|
||||
});
|
||||
// locked: 바깥 클릭·Esc 로 닫지 않는다 (임시 비밀번호 표시·비밀번호 변경 강제) — 취소 버튼은 동작한다
|
||||
function modalLocked() { return !!(modal.opts && modal.opts.locked); }
|
||||
each(modal.el.querySelectorAll('[data-modal-cancel]'), function (el) {
|
||||
el.addEventListener('click', function () { if (!modal.busy) closeModal(null); });
|
||||
el.addEventListener('click', function () {
|
||||
if (modal.busy) return;
|
||||
if (modalLocked() && el.classList.contains('mgm-modal-backdrop')) return;
|
||||
closeModal(null);
|
||||
});
|
||||
});
|
||||
function confirmModal(title, message, confirmText, danger) {
|
||||
return openModal({ title: title, html: '<p>' + message + '</p>', confirmText: confirmText, danger: danger });
|
||||
@@ -1100,7 +1209,7 @@
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (!modal.el.hidden) { if (!modal.busy) closeModal(null); }
|
||||
if (!modal.el.hidden) { if (!modal.busy && !modalLocked()) closeModal(null); }
|
||||
else if (!drawer.hidden) closeDrawer();
|
||||
});
|
||||
|
||||
@@ -1194,14 +1303,42 @@
|
||||
$('[data-role="app"]').hidden = true;
|
||||
$('[data-role="login"]').hidden = false;
|
||||
}
|
||||
function renderAccountName() {
|
||||
var a = state.admin, el = $('[data-role="admin-name"]');
|
||||
var isPartner = a.role === 'partner' && a.partner;
|
||||
el.textContent = isPartner ? a.partner.name : a.username + ' 님';
|
||||
el.title = isPartner ? a.partner.name + ' · 아이디 ' + a.username : a.username;
|
||||
}
|
||||
// 역할에 맞는 메뉴만 보인다 (최고관리자 메뉴는 업체 계정에게 숨긴다)
|
||||
function applyRole(admin) {
|
||||
each($all('[data-role-only]'), function (el) { el.hidden = el.getAttribute('data-role-only') !== admin.role; });
|
||||
$('[data-role="brand-sub"]').textContent = admin.role === 'partner' ? '업체 실적' : '관리자 대시보드';
|
||||
}
|
||||
// 비밀번호 변경 창 뒤에도 들어갈 화면의 제목이 보이게 한다
|
||||
function showViewTitle(view) {
|
||||
$('[data-role="view-title"]').textContent = VIEWS[view].title;
|
||||
$('[data-role="view-desc"]').textContent = VIEWS[view].desc;
|
||||
}
|
||||
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);
|
||||
renderAccountName();
|
||||
applyRole(admin);
|
||||
// 연동 상태는 최고관리자 기능 — 업체 계정은 부르지 않는다
|
||||
if (admin.role === 'super') loadStatus().catch(errorNotice);
|
||||
var saved = storageGet(VIEW_KEY);
|
||||
go(VIEWS[saved] ? saved : 'overview');
|
||||
var view = viewAllowed(saved) ? saved : ROLE_HOME[admin.role];
|
||||
if (admin.mustChangePassword) {
|
||||
showViewTitle(view);
|
||||
changePasswordDialog(true).then(function (ok) { if (ok) go(view); });
|
||||
return;
|
||||
}
|
||||
go(view);
|
||||
}
|
||||
function logout() {
|
||||
// 서버에서 토큰을 무효화한 뒤 화면의 토큰도 지운다 (이미 만료됐어도 화면은 로그아웃된다)
|
||||
rawRequest('DELETE', '/admin/sessions').then(function () { saveSession(null); showLogin(); });
|
||||
}
|
||||
|
||||
$('[data-role="login-form"]').addEventListener('submit', function (e) {
|
||||
@@ -1225,13 +1362,13 @@
|
||||
|
||||
/* ===================== 화면 전환 ===================== */
|
||||
function go(view) {
|
||||
if (!viewAllowed(view)) view = ROLE_HOME[state.admin ? state.admin.role : 'super'];
|
||||
state.view = view;
|
||||
storageSet(VIEW_KEY, view);
|
||||
closeDrawer();
|
||||
each($all('[data-view]'), function (b) { b.classList.toggle('is-active', b.getAttribute('data-view') === view); });
|
||||
each($all('[data-panel]'), function (p) { p.hidden = p.getAttribute('data-panel') !== view; });
|
||||
$('[data-role="view-title"]').textContent = VIEWS[view].title;
|
||||
$('[data-role="view-desc"]').textContent = VIEWS[view].desc;
|
||||
showViewTitle(view);
|
||||
loadView(view);
|
||||
}
|
||||
|
||||
@@ -1241,6 +1378,7 @@
|
||||
else if (view === 'applications') loadApplicationsView();
|
||||
else if (view === 'partners') loadPartnersView();
|
||||
else if (view === 'integration') renderIntegration();
|
||||
else if (view === 'portal') loadPortal();
|
||||
}
|
||||
|
||||
each($all('[data-view]'), function (b) {
|
||||
@@ -1353,8 +1491,9 @@
|
||||
var action = btn.getAttribute('data-action');
|
||||
|
||||
if (action === 'logout') {
|
||||
// 서버에서 토큰을 무효화한 뒤 화면의 토큰도 지운다 (이미 만료됐어도 화면은 로그아웃된다)
|
||||
rawRequest('DELETE', '/admin/sessions').then(function () { saveSession(null); showLogin(); });
|
||||
logout();
|
||||
} else if (action === 'change-password') {
|
||||
changePasswordDialog(false);
|
||||
} else if (action === 'connect') {
|
||||
api('POST', '/admin/imweb/authorizations').then(function (r) {
|
||||
location.href = r.authorizationUrl; // 아임웹 동의 화면 → 끝나면 이 페이지로 돌아온다
|
||||
@@ -1945,6 +2084,7 @@
|
||||
(p.memo ? '<span class="mgm-pitem-memo" title="' + esc(p.memo) + '">' + esc(p.memo) + '</span>' : '') +
|
||||
stats(p.stats, p.linkCount) + '</button>' +
|
||||
(active ? '<div class="mgm-pitem-actions"><button type="button" class="mgm-link mgm-sm" data-ptn-edit-partner="' + esc(p.id) + '">이름·메모 수정</button>' +
|
||||
'<button type="button" class="mgm-link mgm-sm" data-ptn-accounts="' + esc(p.id) + '">계정 관리</button>' +
|
||||
(p.linkCount === 0 ? '<button type="button" class="mgm-link mgm-sm is-danger" data-ptn-delete-partner="' + esc(p.id) + '">삭제</button>' : '') + '</div>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
@@ -2328,6 +2468,8 @@
|
||||
editPartnerDialog(id);
|
||||
} else if ((id = t.getAttribute('data-ptn-delete-partner'))) {
|
||||
deletePartnerDialog(id);
|
||||
} else if ((id = t.getAttribute('data-ptn-accounts'))) {
|
||||
openAccounts(id);
|
||||
} else if (t.hasAttribute('data-ptn-copy')) {
|
||||
copyWithToast(t.getAttribute('data-ptn-copy'));
|
||||
} else if ((id = t.getAttribute('data-ptn-conv'))) {
|
||||
@@ -2339,6 +2481,271 @@
|
||||
}
|
||||
});
|
||||
|
||||
/* ===================== 내 업체 실적 (업체 계정) ===================== */
|
||||
// 업체 계정은 이 화면만 본다. 업체 ID 는 서버가 로그인 정보에서 정한다 (화면이 보내지 않는다)
|
||||
function portalFailed(err) {
|
||||
if (err && err.code === 'ACCOUNT_003') {
|
||||
changePasswordDialog(true).then(function (ok) { if (ok) loadPortal(); });
|
||||
return;
|
||||
}
|
||||
errorNotice(err);
|
||||
}
|
||||
|
||||
function loadPortal() {
|
||||
var p = state.portal;
|
||||
setSeg('[data-portal-range]', 'data-portal-range', p.range);
|
||||
setSeg('[data-portal-type]', 'data-portal-type', p.type);
|
||||
var period = periodQuery(p.range, 'from', 'to');
|
||||
api('GET', '/partner-portal/summary' + toQuery(period)).then(function (s) {
|
||||
setKpi('portal-clicks', num(s.stats.clickCount));
|
||||
setKpi('portal-apps', num(s.stats.applicationCount));
|
||||
setKpi('portal-apps-sub', '클릭 대비 ' + pct(s.stats.applicationCount, s.stats.clickCount));
|
||||
setKpi('portal-buys', num(s.stats.purchaseCount));
|
||||
setKpi('portal-links', num(s.linkCount));
|
||||
}).catch(portalFailed);
|
||||
api('GET', '/partner-portal/links' + toQuery(Object.assign({ limit: LIST_LIMIT }, period))).then(function (page) {
|
||||
$('[data-role="portal-link-rows"]').innerHTML = page.items.map(portalLinkRowHtml).join('');
|
||||
$('[data-role="portal-link-empty"]').hidden = page.items.length > 0;
|
||||
var total = page.pageInfo.total;
|
||||
$('[data-role="portal-link-total"]').textContent = total ? num(total) + '개' : '';
|
||||
}).catch(portalFailed);
|
||||
loadPortalConversions(true);
|
||||
}
|
||||
|
||||
function portalLinkRowHtml(l) {
|
||||
return '<tr>' +
|
||||
'<td class="mgm-wrap"><span class="mgm-cell-main">' + esc(l.product.name || '상품 #' + l.product.prodNo) + '</span>' +
|
||||
(l.product.name ? '<span class="mgm-cell-sub">#' + esc(l.product.prodNo) + '</span>' : '') + '</td>' +
|
||||
'<td><div class="mgm-linkcell"><div class="mgm-linkcell-top"><span class="mgm-code">' + esc(l.shortUrl) + '</span>' +
|
||||
'<button type="button" class="mgm-link mgm-sm" data-portal-copy="' + esc(l.shortUrl) + '">복사</button></div></div></td>' +
|
||||
'<td class="mgm-r">' + num(l.stats.clickCount) + '</td>' +
|
||||
'<td class="mgm-r"><b>' + num(l.stats.applicationCount) + '</b></td>' +
|
||||
'<td class="mgm-r"><b>' + num(l.stats.purchaseCount) + '</b></td>' +
|
||||
'<td>' + (l.isActive ? '<span class="mgm-badge is-ok">사용 중</span>' : '<span class="mgm-badge" title="운영사가 중지한 링크 — 눌러도 실적이 쌓이지 않습니다">중지됨</span>') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function portalConvRowHtml(c) {
|
||||
return '<tr>' +
|
||||
'<td>' + whenCell(c.convertedAt) + '</td>' +
|
||||
'<td>' + (c.type === 'purchase' ? '<span class="mgm-badge is-accent">구매</span>' : '<span class="mgm-badge is-ok">신청</span>') + '</td>' +
|
||||
'<td>' + dash(c.maskedName) + (c.isMember ? '' : ' <span class="mgm-faint">비회원</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + esc(c.product.name || '상품 #' + c.product.prodNo) + '</td>' +
|
||||
'<td>' + (c.isValid ? '<span class="mgm-faint">-</span>' : '<span class="mgm-badge is-danger" title="환불·취소되어 실적에서 빠졌습니다">환불·취소</span>') + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
// 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다
|
||||
function loadPortalConversions(reset) {
|
||||
var p = state.portal;
|
||||
if (!reset && p.loading) return;
|
||||
var seq = ++p.seq;
|
||||
p.loading = true;
|
||||
if (reset) p.cursor = null;
|
||||
var q = Object.assign({ limit: PAGE_SIZE }, periodQuery(p.range, 'from', 'to'));
|
||||
if (p.type) q.type = p.type;
|
||||
if (p.cursor) q.cursor = p.cursor;
|
||||
var moreBtn = $('[data-action="portal-conv-more"]');
|
||||
moreBtn.disabled = true;
|
||||
api('GET', '/partner-portal/conversions' + toQuery(q)).then(function (page) {
|
||||
if (seq !== p.seq) return;
|
||||
var tbody = $('[data-role="portal-conv-rows"]');
|
||||
var html = page.items.map(portalConvRowHtml).join('');
|
||||
if (reset) tbody.innerHTML = html || '<tr><td colspan="5" class="mgm-empty">이 기간의 신청·구매가 없습니다.</td></tr>';
|
||||
else tbody.insertAdjacentHTML('beforeend', html);
|
||||
p.cursor = page.pageInfo.nextCursor;
|
||||
moreBtn.hidden = !page.pageInfo.hasNext;
|
||||
var total = page.pageInfo.total;
|
||||
$('[data-role="portal-conv-total"]').textContent = total === null ? '' : num(total) + '건';
|
||||
}).catch(function (err) {
|
||||
if (seq === p.seq) portalFailed(err);
|
||||
}).then(function () {
|
||||
if (seq === p.seq) { p.loading = false; moreBtn.disabled = false; }
|
||||
});
|
||||
}
|
||||
|
||||
each($all('[data-portal-range]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.portal.range = b.getAttribute('data-portal-range');
|
||||
loadPortal();
|
||||
});
|
||||
});
|
||||
each($all('[data-portal-type]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.portal.type = b.getAttribute('data-portal-type');
|
||||
setSeg('[data-portal-type]', 'data-portal-type', state.portal.type);
|
||||
loadPortalConversions(true);
|
||||
});
|
||||
});
|
||||
$('[data-panel="portal"]').addEventListener('click', function (e) {
|
||||
var t = closest(e.target, 'button');
|
||||
if (!t) return;
|
||||
if (t.getAttribute('data-action') === 'portal-conv-more') loadPortalConversions(false);
|
||||
else if (t.hasAttribute('data-portal-copy')) copyWithToast(t.getAttribute('data-portal-copy'));
|
||||
});
|
||||
|
||||
/* ----- 비밀번호 변경 (업체 계정 본인) ----- */
|
||||
// 임시 비밀번호를 바꾸기 전에는(forced) 닫을 수 없고, 대신 로그아웃할 수 있다
|
||||
var passwordDialog = null;
|
||||
function changePasswordDialog(forced) {
|
||||
if (passwordDialog) return passwordDialog;
|
||||
passwordDialog = openModal({
|
||||
title: forced ? '비밀번호를 바꿔 주세요' : '비밀번호 변경',
|
||||
confirmText: '변경',
|
||||
cancelText: forced ? '로그아웃' : '취소',
|
||||
locked: forced,
|
||||
html: (forced ? '<p>처음 받은 임시 비밀번호를 내 비밀번호로 바꿔야 실적을 볼 수 있습니다.</p>' : '') +
|
||||
'<label class="mgm-field">현재 비밀번호' + (forced ? ' (받은 임시 비밀번호)' : '') +
|
||||
'<input name="currentPassword" type="password" autocomplete="current-password" required maxlength="72"></label>' +
|
||||
'<label class="mgm-field">새 비밀번호<input name="newPassword" type="password" autocomplete="new-password" required maxlength="72"></label>' +
|
||||
'<label class="mgm-field">새 비밀번호 확인<input name="newPasswordConfirm" type="password" autocomplete="new-password" required maxlength="72"></label>' +
|
||||
'<p class="mgm-hint">' + PASSWORD_MIN_LENGTH + '자 이상. 바꾸면 다른 기기에서는 로그아웃됩니다.</p>',
|
||||
submit: function (v) {
|
||||
if (v.newPassword.length < PASSWORD_MIN_LENGTH) return Promise.reject('새 비밀번호는 ' + PASSWORD_MIN_LENGTH + '자 이상이어야 합니다.');
|
||||
if (v.newPassword !== v.newPasswordConfirm) return Promise.reject('새 비밀번호가 서로 다릅니다.');
|
||||
return api('PUT', '/admin/accounts/current/password', {
|
||||
currentPassword: v.currentPassword,
|
||||
newPassword: v.newPassword
|
||||
}).then(function (issued) {
|
||||
state.admin = storeIssued(issued);
|
||||
renderAccountName();
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}).then(function (ok) {
|
||||
passwordDialog = null;
|
||||
if (ok) notify('비밀번호를 변경했습니다.');
|
||||
else if (forced) logout();
|
||||
return !!ok;
|
||||
});
|
||||
return passwordDialog;
|
||||
}
|
||||
|
||||
/* ----- 업체 계정 관리 (최고관리자) ----- */
|
||||
function loginUrl() { return location.origin + location.pathname; }
|
||||
|
||||
function openAccounts(partnerId) {
|
||||
var p = findPartner(partnerId);
|
||||
if (!p) return;
|
||||
state.ptn.accountsPartnerId = partnerId;
|
||||
openDrawer(p.name + ' · 업체 계정', '<p class="mgm-muted">불러오는 중…</p>');
|
||||
loadAccounts();
|
||||
}
|
||||
|
||||
function loadAccounts() {
|
||||
var partnerId = state.ptn.accountsPartnerId;
|
||||
return api('GET', '/admin/partners/' + encodeURIComponent(partnerId) + '/accounts' + toQuery({ limit: LIST_LIMIT }))
|
||||
.then(function (page) {
|
||||
if (state.ptn.accountsPartnerId !== partnerId || drawer.hidden) return;
|
||||
$('[data-role="drawer-body"]').innerHTML = accountsHtml(page.items);
|
||||
}).catch(errorNotice);
|
||||
}
|
||||
|
||||
function accountsHtml(items) {
|
||||
var rows = items.map(function (a) {
|
||||
return '<tr>' +
|
||||
'<td><span class="mgm-cell-main mgm-code">' + esc(a.username) + '</span>' +
|
||||
(a.mustChangePassword ? '<span class="mgm-cell-sub">임시 비밀번호 — 아직 바꾸지 않음</span>' : '') + '</td>' +
|
||||
'<td>' + (a.lastLoginAt ? esc(kstShort(a.lastLoginAt)) : '<span class="mgm-faint">없음</span>') + '</td>' +
|
||||
'<td><button type="button" class="mgm-switch" data-acc-toggle="' + esc(a.id) + '" data-acc-active="' + a.isActive + '" aria-pressed="' + a.isActive + '" ' +
|
||||
'aria-label="' + (a.isActive ? '사용 중 — 눌러서 중지' : '중지됨 — 눌러서 다시 사용') + '" title="' + (a.isActive ? '사용 중' : '중지됨') + '"></button></td>' +
|
||||
'<td><button type="button" class="mgm-link mgm-sm" data-acc-reset="' + esc(a.id) + '" data-acc-name="' + esc(a.username) + '">비밀번호 초기화</button></td>' +
|
||||
'</tr>';
|
||||
}).join('');
|
||||
return '<section class="mgm-dsec">' +
|
||||
'<p class="mgm-muted">업체 계정은 이 업체의 링크·클릭·신청·구매 수와, 이름 일부를 가린 신청·구매 목록만 봅니다. 연락처·이메일과 다른 메뉴는 보이지 않습니다.</p>' +
|
||||
'<div class="mgm-row" style="margin:12px 0"><button type="button" class="mgm-primary" data-acc-new>+ 계정 만들기</button></div>' +
|
||||
(items.length
|
||||
? '<div class="mgm-table-wrap"><table><thead><tr><th>아이디</th><th>마지막 로그인</th><th>사용</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div>'
|
||||
: '<div class="mgm-empty"><b>만든 계정이 없습니다</b>업체가 실적을 볼 로그인 계정을 만드세요.</div>') +
|
||||
'<p class="mgm-hint">업체 로그인 주소: <span class="mgm-code">' + esc(loginUrl()) + '</span></p>' +
|
||||
'</section>';
|
||||
}
|
||||
|
||||
function credentialText(cred) {
|
||||
return '로그인 주소: ' + loginUrl() + '\n아이디: ' + cred.account.username + '\n임시 비밀번호: ' + cred.temporaryPassword;
|
||||
}
|
||||
// 임시 비밀번호는 이 창에서 한 번만 보여준다 — 실수로 닫지 않게 바깥 클릭·Esc 로는 닫히지 않는다
|
||||
function showCredential(cred, title) {
|
||||
state.credentialText = credentialText(cred);
|
||||
return openModal({
|
||||
title: title,
|
||||
hideCancel: true,
|
||||
locked: true,
|
||||
confirmText: '전달했습니다',
|
||||
html: '<p>아래 아이디와 임시 비밀번호를 업체에 전달하세요. <b>이 창을 닫으면 비밀번호를 다시 볼 수 없습니다.</b></p>' +
|
||||
dlHtml([
|
||||
['로그인 주소', '<span class="mgm-code">' + esc(loginUrl()) + '</span>'],
|
||||
['아이디', '<span class="mgm-code">' + esc(cred.account.username) + '</span>']
|
||||
]) +
|
||||
'<div class="mgm-secret" aria-label="임시 비밀번호">' + esc(cred.temporaryPassword) + '</div>' +
|
||||
'<div class="mgm-row"><button type="button" data-copy-credential>주소·아이디·비밀번호 함께 복사</button></div>' +
|
||||
'<p class="mgm-hint">업체는 첫 로그인 때 비밀번호를 직접 바꿔야 실적을 볼 수 있습니다.</p>'
|
||||
}).then(function () { state.credentialText = ''; });
|
||||
}
|
||||
modal.el.addEventListener('click', function (e) {
|
||||
if (!closest(e.target, '[data-copy-credential]') || !state.credentialText) return;
|
||||
// 복사한 내용(비밀번호)은 알림에 보여주지 않는다
|
||||
copyText(state.credentialText).then(function (ok) {
|
||||
if (ok) notify('복사했습니다.');
|
||||
else toast('복사하지 못했습니다. 직접 선택해 복사해 주세요.', 'error');
|
||||
});
|
||||
});
|
||||
|
||||
function newAccountDialog() {
|
||||
var partnerId = state.ptn.accountsPartnerId, p = findPartner(partnerId);
|
||||
openModal({
|
||||
title: '업체 계정 만들기',
|
||||
confirmText: '만들기',
|
||||
html: '<p>' + esc(p ? p.name : '') + ' 업체가 실적을 볼 때 쓸 로그인 계정입니다.</p>' +
|
||||
'<label class="mgm-field">아이디<input name="username" required maxlength="30" autocomplete="off" placeholder="영문·숫자 4~30자 (예: moneytube)"></label>' +
|
||||
'<p class="mgm-hint">비밀번호는 서버가 만들어 한 번만 보여 드립니다.</p>',
|
||||
submit: function (v) {
|
||||
if (!v.username.trim()) return Promise.reject('아이디를 입력해 주세요.');
|
||||
return api('POST', '/admin/partners/' + encodeURIComponent(partnerId) + '/accounts', { username: v.username.trim() });
|
||||
}
|
||||
}).then(function (cred) {
|
||||
if (!cred) return;
|
||||
loadAccounts();
|
||||
showCredential(cred, '계정을 만들었습니다');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAccount(id, isActive) {
|
||||
var confirmed = isActive
|
||||
? confirmModal('계정 중지', '이 계정으로는 더 이상 로그인할 수 없고, 열려 있는 화면도 바로 로그아웃됩니다. 중지할까요?', '중지', true)
|
||||
: Promise.resolve(true);
|
||||
confirmed.then(function (ok) {
|
||||
if (!ok) return null;
|
||||
return api('PATCH', '/admin/partner-accounts/' + encodeURIComponent(id), { isActive: !isActive }).then(function () {
|
||||
notify(isActive ? '계정을 중지했습니다.' : '계정을 다시 사용합니다.');
|
||||
return loadAccounts();
|
||||
});
|
||||
}).catch(errorNotice);
|
||||
}
|
||||
|
||||
function resetAccountDialog(id, username) {
|
||||
confirmModal('비밀번호 초기화',
|
||||
'<b>' + esc(username) + '</b> 계정의 비밀번호를 새 임시 비밀번호로 바꿉니다. 열려 있는 화면은 로그아웃되고, 업체는 다음 로그인 때 비밀번호를 다시 바꿔야 합니다.',
|
||||
'초기화', true)
|
||||
.then(function (ok) {
|
||||
if (!ok) return null;
|
||||
return api('POST', '/admin/partner-accounts/' + encodeURIComponent(id) + '/password-resets').then(function (cred) {
|
||||
loadAccounts();
|
||||
showCredential(cred, '비밀번호를 초기화했습니다');
|
||||
});
|
||||
}).catch(errorNotice);
|
||||
}
|
||||
|
||||
$('[data-role="drawer-body"]').addEventListener('click', function (e) {
|
||||
var t = closest(e.target, 'button');
|
||||
if (!t) return;
|
||||
var id;
|
||||
if (t.hasAttribute('data-acc-new')) newAccountDialog();
|
||||
else if ((id = t.getAttribute('data-acc-toggle'))) toggleAccount(id, t.getAttribute('data-acc-active') === 'true');
|
||||
else if ((id = t.getAttribute('data-acc-reset'))) resetAccountDialog(id, t.getAttribute('data-acc-name'));
|
||||
});
|
||||
|
||||
/* ===================== 회원 목록 필터 ===================== */
|
||||
// 버튼 없이 바로 적용한다 — 선택형은 바꾸는 즉시, 검색어는 입력을 멈추면
|
||||
var FILTER_KEYS = ['inflowStatus', 'source', 'medium', 'campaign', 'content', 'q'];
|
||||
|
||||
Reference in New Issue
Block a user