@@ -645,7 +736,8 @@
링크를 누른 뒤 30일 안에 신청·구매하면, 그 상품의 링크 중 마지막으로 누른 업체 실적으로 잡습니다.
구매는 결제완료 주문만 셉니다. 환불·취소되면 빠집니다. 아임웹 주문은 1시간마다 확인합니다.
링크로 들어온 방문에는 업체 UTM(유입원=업체 이름, 매체=partner, 캠페인=링크 코드)이 붙어 신청 유입·가입 경로에도 업체가 나옵니다.
-
링크를 누른 기기와 신청·구매한 기기가 다르거나, 비회원 주문이면 추적되지 않습니다.
+
링크를 누른 기기와 신청·구매한 기기가 다르면 추적되지 않습니다. 비회원 주문은 주문완료 화면에서 링크 클릭이 확인될 때만 잡힙니다.
+
업체를 고르고 계정 관리에서 업체용 로그인 계정을 만들면, 업체가 자기 링크·실적을 직접 볼 수 있습니다 (이름은 일부만, 연락처는 보이지 않음).
@@ -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: '
' + message + '
', 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 ? '
' + esc(p.memo) + '' : '') +
stats(p.stats, p.linkCount) + '' +
(active ? '
' +
+ '' +
(p.linkCount === 0 ? '' : '') + '
' : '') +
'
';
}).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 '' +
+ '| ' + esc(l.product.name || '상품 #' + l.product.prodNo) + '' +
+ (l.product.name ? '#' + esc(l.product.prodNo) + '' : '') + ' | ' +
+ '' + esc(l.shortUrl) + '' +
+ ' | ' +
+ '' + num(l.stats.clickCount) + ' | ' +
+ '' + num(l.stats.applicationCount) + ' | ' +
+ '' + num(l.stats.purchaseCount) + ' | ' +
+ '' + (l.isActive ? '사용 중' : '중지됨') + ' | ' +
+ '
';
+ }
+
+ function portalConvRowHtml(c) {
+ return '' +
+ '| ' + whenCell(c.convertedAt) + ' | ' +
+ '' + (c.type === 'purchase' ? '구매' : '신청') + ' | ' +
+ '' + dash(c.maskedName) + (c.isMember ? '' : ' 비회원') + ' | ' +
+ '' + esc(c.product.name || '상품 #' + c.product.prodNo) + ' | ' +
+ '' + (c.isValid ? '-' : '환불·취소') + ' | ' +
+ '
';
+ }
+
+ // 필터를 빠르게 바꿔도 마지막 요청 결과만 그린다
+ 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 || '| 이 기간의 신청·구매가 없습니다. |
';
+ 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 ? '처음 받은 임시 비밀번호를 내 비밀번호로 바꿔야 실적을 볼 수 있습니다.
' : '') +
+ '' +
+ '' +
+ '' +
+ '' + PASSWORD_MIN_LENGTH + '자 이상. 바꾸면 다른 기기에서는 로그아웃됩니다.
',
+ 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 + ' · 업체 계정', '불러오는 중…
');
+ 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 '' +
+ '| ' + esc(a.username) + '' +
+ (a.mustChangePassword ? '임시 비밀번호 — 아직 바꾸지 않음' : '') + ' | ' +
+ '' + (a.lastLoginAt ? esc(kstShort(a.lastLoginAt)) : '없음') + ' | ' +
+ ' | ' +
+ ' | ' +
+ '
';
+ }).join('');
+ return '' +
+ '업체 계정은 이 업체의 링크·클릭·신청·구매 수와, 이름 일부를 가린 신청·구매 목록만 봅니다. 연락처·이메일과 다른 메뉴는 보이지 않습니다.
' +
+ '' +
+ (items.length
+ ? ''
+ : '만든 계정이 없습니다업체가 실적을 볼 로그인 계정을 만드세요.
') +
+ '업체 로그인 주소: ' + esc(loginUrl()) + '
' +
+ '';
+ }
+
+ 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: '아래 아이디와 임시 비밀번호를 업체에 전달하세요. 이 창을 닫으면 비밀번호를 다시 볼 수 없습니다.
' +
+ dlHtml([
+ ['로그인 주소', '' + esc(loginUrl()) + ''],
+ ['아이디', '' + esc(cred.account.username) + '']
+ ]) +
+ '' + esc(cred.temporaryPassword) + '
' +
+ '' +
+ '업체는 첫 로그인 때 비밀번호를 직접 바꿔야 실적을 볼 수 있습니다.
'
+ }).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: '' + esc(p ? p.name : '') + ' 업체가 실적을 볼 때 쓸 로그인 계정입니다.
' +
+ '' +
+ '비밀번호는 서버가 만들어 한 번만 보여 드립니다.
',
+ 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('비밀번호 초기화',
+ '' + esc(username) + ' 계정의 비밀번호를 새 임시 비밀번호로 바꿉니다. 열려 있는 화면은 로그아웃되고, 업체는 다음 로그인 때 비밀번호를 다시 바꿔야 합니다.',
+ '초기화', 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'];