generated from ttipo/project-base
feat: 신청 때 최종 유입 저장과 신청 유입 집계 추가
가입 경로와 신청(수강) 유입을 구분해 보기 위해서다. 기존 신청 유입은 신청 시점 브라우저의 '최초' 유입(2일 보관)이라, 그 사이 다른 경로로 다시 와서 신청하면 신청을 일으킨 경로가 가려졌다. - member_applications 에 최종 유입(inflow_last_*) 저장 — 없으면 최초 유입으로 채움 (기존 행도 동일) - 신청 완료 위젯이 MG_UTM.peek().last 를 lastInflow 로 함께 보냄 - GET /api/admin/applications/inflow-stats: 신청 때 최종·최초 / 가입 경로(가입 때 기록만) 기준 집계 - 신청 목록에 inflowBasis·inflowField·inflowValue 필터 - 관리자 화면 신청 내역: '신청 유입 집계' 카드(기준·항목 전환, 막대 클릭으로 목록 필터), 신청 때 유입 칸에 최종 유입 표시 + 최초가 다르면 함께 표시 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+103
-4
@@ -505,9 +505,30 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<section class="mgm-card" style="margin-bottom:16px">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청 유입 집계</h3>
|
||||
<span class="mgm-muted" data-role="app-stats-desc">위 기간·상품 조건 · 막대를 누르면 아래 목록을 거릅니다</span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<div class="mgm-seg" role="group" aria-label="유입 기준">
|
||||
<button type="button" data-app-basis="apply_last" class="is-active" title="신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입">신청 때 (최종)</button>
|
||||
<button type="button" data-app-basis="apply_first" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">신청 때 (최초)</button>
|
||||
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
|
||||
</div>
|
||||
<div class="mgm-seg" role="group" aria-label="집계 항목">
|
||||
<button type="button" data-app-group="source" class="is-active">유입원</button>
|
||||
<button type="button" data-app-group="medium">매체</button>
|
||||
<button type="button" data-app-group="campaign">캠페인</button>
|
||||
<button type="button" data-app-group="content">콘텐츠</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
|
||||
</section>
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청</h3>
|
||||
<span data-role="app-inflow-filter" class="mgm-row" style="gap:6px"></span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<span class="mgm-muted" data-role="app-total"></span>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
@@ -515,7 +536,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일시</th><th>상품</th><th>이름</th><th>아이디 / 이메일</th><th>연락처</th>
|
||||
<th>오픈카톡</th><th>신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
|
||||
<th>오픈카톡</th><th title="신청 직전 최종 유입 — 첫 경로와 다르면 아래에 '최초'로 함께 표시">신청 때 유입</th><th>가입 때 유입</th><th>회원 그룹</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="app-rows"></tbody>
|
||||
@@ -699,7 +720,11 @@
|
||||
view: 'overview',
|
||||
cursor: null,
|
||||
listSeq: 0,
|
||||
app: { range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true },
|
||||
app: {
|
||||
range: 'all', product: '', cursor: null, loading: false, seq: 0, products: null, productsStale: true,
|
||||
// 신청 유입 집계 — basis: apply_last | apply_first | join, inflowFilter: 막대를 눌러 건 목록 필터
|
||||
basis: 'apply_last', group: 'source', statsSeq: 0, inflowFilter: null
|
||||
},
|
||||
// 업체 링크 — partnerId 는 링크 목록 필터, conv 는 열어 둔 실적 상세
|
||||
ptn: {
|
||||
range: 'all', partnerId: '', cursor: null, loading: false, seq: 0,
|
||||
@@ -1421,9 +1446,52 @@
|
||||
var q = appForm.q.value.trim();
|
||||
if (q) out.q = q;
|
||||
if (state.app.product) out.productIdx = state.app.product;
|
||||
var inf = state.app.inflowFilter;
|
||||
if (inf) { out.inflowBasis = inf.basis; out.inflowField = inf.field; out.inflowValue = inf.value; }
|
||||
return out;
|
||||
}
|
||||
|
||||
/* 신청 유입 집계 — 같은 신청을 신청 때 유입 / 가입 경로로 나눠 센다 */
|
||||
var APP_BASIS_LABEL = { apply_last: '신청 때(최종)', apply_first: '신청 때(최초)', join: '가입 경로' };
|
||||
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
|
||||
var APP_STATS_TOP = 12;
|
||||
|
||||
function loadAppStats() {
|
||||
var app = state.app;
|
||||
var seq = ++app.statsSeq;
|
||||
var q = { basis: app.basis, groupBy: app.group };
|
||||
var f = appFilters(); // 기간·상품만 쓴다 (검색어·유입 필터는 목록 전용)
|
||||
if (f.appliedFrom) q.appliedFrom = f.appliedFrom;
|
||||
if (f.appliedTo) q.appliedTo = f.appliedTo;
|
||||
if (f.productIdx) q.productIdx = f.productIdx;
|
||||
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
|
||||
if (seq !== app.statsSeq) return;
|
||||
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, APP_STATS_TOP), true);
|
||||
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' +
|
||||
APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 아래 목록을 거릅니다';
|
||||
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
|
||||
}
|
||||
|
||||
function renderAppInflowFilter() {
|
||||
var inf = state.app.inflowFilter;
|
||||
$('[data-role="app-inflow-filter"]').innerHTML = inf
|
||||
? '<span class="mgm-active-chip"><b>' + esc(APP_BASIS_LABEL[inf.basis] + ' ' + APP_GROUP_LABEL[inf.field]) + '</b> ' +
|
||||
esc(inf.value) + '<button type="button" data-app-clear-inflow aria-label="유입 필터 지우기">×</button></span>'
|
||||
: '';
|
||||
}
|
||||
|
||||
// 신청 때 유입 칸 — 최종 유입을 보여주고, 첫 경로가 다르면 '최초'로 함께 적는다
|
||||
function applyInflowCell(a) {
|
||||
var first = a.inflow, last = a.lastInflow && a.lastInflow.source ? a.lastInflow : first;
|
||||
var html = inflowText(last);
|
||||
if (first && first.source && last && last.source &&
|
||||
(first.source !== last.source || first.medium !== last.medium || first.campaign !== last.campaign)) {
|
||||
html += '<br><span class="mgm-muted" title="신청 시점 브라우저에 남아 있던 첫 경로 (2일 보관)">최초: ' +
|
||||
esc(first.source) + (first.medium ? ' / ' + esc(first.medium) : '') + '</span>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function productLabel(p) {
|
||||
return (p.productName || '상품') + ' #' + p.productIdx;
|
||||
}
|
||||
@@ -1473,7 +1541,7 @@
|
||||
'<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">' + applyInflowCell(a) + '</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('')
|
||||
@@ -1487,7 +1555,7 @@
|
||||
if (!reset && app.loading) return;
|
||||
var seq = ++app.seq;
|
||||
app.loading = true;
|
||||
if (reset) { app.cursor = null; loadAppProducts(); }
|
||||
if (reset) { app.cursor = null; loadAppProducts(); loadAppStats(); renderAppInflowFilter(); }
|
||||
|
||||
var q = appFilters();
|
||||
q.limit = PAGE_SIZE;
|
||||
@@ -1534,6 +1602,37 @@
|
||||
loadApplications(true);
|
||||
});
|
||||
|
||||
// 집계 기준·항목 바꾸기 — 집계만 다시 센다 (목록 필터는 막대를 누를 때 바뀐다)
|
||||
each($all('[data-app-basis]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.app.basis = b.getAttribute('data-app-basis');
|
||||
each($all('[data-app-basis]'), function (x) { x.classList.toggle('is-active', x === b); });
|
||||
loadAppStats();
|
||||
});
|
||||
});
|
||||
each($all('[data-app-group]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.app.group = b.getAttribute('data-app-group');
|
||||
each($all('[data-app-group]'), function (x) { x.classList.toggle('is-active', x === b); });
|
||||
loadAppStats();
|
||||
});
|
||||
});
|
||||
// 막대 → 같은 기준·항목·값으로 목록을 거른다 (다시 누르면 해제)
|
||||
$('[data-role="app-stats-bars"]').addEventListener('click', function (e) {
|
||||
var bar = e.target.closest ? e.target.closest('.mgm-bar') : null;
|
||||
if (!bar || bar.disabled) return;
|
||||
var value = bar.getAttribute('data-key');
|
||||
var cur = state.app.inflowFilter;
|
||||
var same = cur && cur.basis === state.app.basis && cur.field === state.app.group && cur.value === value;
|
||||
state.app.inflowFilter = same ? null : { basis: state.app.basis, field: state.app.group, value: value };
|
||||
loadApplications(true);
|
||||
});
|
||||
$('[data-role="app-inflow-filter"]').addEventListener('click', function (e) {
|
||||
if (!e.target.closest || !e.target.closest('[data-app-clear-inflow]')) return;
|
||||
state.app.inflowFilter = null;
|
||||
loadApplications(true);
|
||||
});
|
||||
|
||||
var appSearchTimer = null;
|
||||
appForm.q.addEventListener('input', function () {
|
||||
clearTimeout(appSearchTimer);
|
||||
|
||||
Reference in New Issue
Block a user