generated from ttipo/project-base
feat: 무료 신청과 유료 구매를 한 화면에서 보는 신청·구매 내역 추가
유료 구매는 업체 링크로 귀속된 것만 남아 누가 구매했는지 볼 수 없었다. 주문 동기화가 결제된 아임웹 주문을 회원·비회원 모두 purchases 에 남기고 (서버 기동 후 첫 실행 1년, 이후 60일), 관리자 조회는 신청과 구매를 합쳐 보여주는 /admin/activities 로 옮겼다. 예전 /admin/applications 조회는 없앴다. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+29
-8
@@ -72,23 +72,33 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
- 2026-10-05 `회원2026_10_05_1.xls` (2026-08-20 ~ 09-07 가입자 2,489명) 가져옴.
|
||||
유입경로 항목이 생긴 2026-08-20 이전 가입자(약 12,500명)는 복구할 수 없다.
|
||||
|
||||
## 강의 신청 기록 (신청 완료 화면 → 신청 내역)
|
||||
## 신청·구매 내역 (관리자 화면)
|
||||
|
||||
관리자 화면 **신청·구매** 메뉴는 무료 신청과 유료 구매를 한 화면에서 본다
|
||||
(`GET /api/admin/activities` · `/groups` · `/inflow-stats`).
|
||||
|
||||
- 종류 [전체 · 무료 신청 · 유료 구매] 로 목록을 거르고, 위쪽 표에서 상품별·업체별 신청 수·구매 수를 함께 본다
|
||||
- 구매 수는 결제완료만 센다 — 환불·취소된 구매는 목록에 '환불·취소'로 남고, 표에는 '환불 N'으로 따로 적는다
|
||||
- 유입 집계: '신청 때 유입'은 무료 신청에만 있다 (구매 시점 유입은 아임웹 주문에 남지 않는다).
|
||||
구매는 '가입 경로'(회원이 가입할 때 기록된 경로)로만 볼 수 있고, 비회원 구매는 값이 없다
|
||||
|
||||
## 강의 신청 기록 (신청 완료 화면 → 신청·구매 내역)
|
||||
|
||||
아임웹 `/apply` 페이지의 신청 완료 위젯(`imweb-free-apply.html`)이 표시될 때
|
||||
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청 내역** 메뉴에서 본다.
|
||||
`POST /api/applications` 로 신청을 남긴다. 관리자 화면 **신청·구매** 메뉴에서 본다.
|
||||
|
||||
- 보내는 값: `MEMBER_UID`(회원 아이디 또는 회원 코드), 상품번호(`idx`), 상품명,
|
||||
최초 유입(`MG_UTM.peek().value`), 최종 유입(`MG_UTM.peek().last` 를 같은 파이프 형식으로),
|
||||
업체 링크 클릭 ID(`MG_PARTNER.refs()`) — `peek()` 는 문자열이 아니라 객체를 돌려준다
|
||||
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 신청 내역의 '신청 유입 집계'에서 기준을 바꿔 비교한다
|
||||
(최종 유입은 저장만 하고 화면에는 보여주지 않는다)
|
||||
- **가입 경로와 신청 유입은 따로 남는다** — 관리자 화면 '유입' 집계에서 기준을 바꿔 비교한다
|
||||
|
||||
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 어제 블로그로 방문 → 오늘 페이스북 광고로 와서 신청 |
|
||||
| 기준 | 값 | 예) 3주 전 네이버로 가입 → 오늘 페이스북 광고로 와서 신청 |
|
||||
| --- | --- | --- |
|
||||
| 신청 때 (최종) | 신청 직전에 마지막으로 들어온 경로 — 신청을 일으킨 유입 | facebook |
|
||||
| 신청 때 (최초) | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | blog |
|
||||
| 신청 때 유입 | 신청 시점 브라우저에 남아 있던 첫 경로 (UTM 스크립트 보관 2일) | facebook |
|
||||
| 가입 경로 | 그 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외) | naver |
|
||||
|
||||
유입이 한 번뿐이면 최초 = 최종이다. 다른 기기에서 신청하면 신청 유입은 direct 로 남는다.
|
||||
다른 기기에서 신청하면 신청 때 유입은 direct 로 남는다.
|
||||
- 백엔드는 아임웹에 실제로 있는 회원인지 확인하고(동기화 전 신규 가입자는 아임웹 API 로 직접 확인),
|
||||
회원·상품당 한 건만 남긴다. 이름·연락처는 브라우저 값이 아니라 아임웹 회원 정보를 쓴다
|
||||
- 오픈카톡 버튼 클릭은 `POST /api/applications/{id}/kakao-clicks` 로 처음 한 번만 남는다
|
||||
@@ -97,6 +107,17 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
- 한계: 신청 완료 화면은 공개 페이지라 "실제로 그 회원이 보냈는지"는 확인할 수 없다
|
||||
(있는 회원인지만 확인). 무료 신청 기록 용도라 이 수준으로 둔다
|
||||
|
||||
## 유료 구매 기록 (아임웹 주문 → 신청·구매 내역)
|
||||
|
||||
아임웹은 주문 완료를 알려주지 않으므로(웹훅 없음) 서버가 1시간마다 아임웹 주문(`GET /orders`)을 훑어
|
||||
결제된 주문을 `purchases` 에 주문·상품당 한 줄로 남긴다. 화면의 '구매 집계 새로고침'(업체 링크 메뉴)으로 바로 돌릴 수도 있다.
|
||||
|
||||
- 회원·비회원 주문을 모두 남긴다. 비회원은 주문서의 주문자 이름·이메일·연락처로 보여준다
|
||||
- 결제 전·결제 실패·입금 전 취소 주문은 남기지 않는다. 나중에 환불·취소되면 지우지 않고 '환불·취소'로 바꾼다
|
||||
- 서버가 뜬 뒤 첫 실행은 **최근 1년**, 그 뒤로는 **최근 60일**만 다시 본다 (60일보다 오래된 주문의 환불은 반영되지 않는다)
|
||||
- 사은품 품목은 넣지 않는다. 0원 주문도 결제완료면 구매로 남는다
|
||||
- 금액은 관리하지 않는다
|
||||
|
||||
## 업체 링크 (업체별 신청·구매 추적)
|
||||
|
||||
상품마다 업체에 단축 링크를 발급하고, 그 링크로 들어온 회원의 신청·구매를 업체별로 센다.
|
||||
@@ -116,7 +137,7 @@ docker exec -u root moneyguide-backend rm /tmp/members-1.xls
|
||||
│ ② 로그인 회원이 확인되면 "이 회원이 이 클릭으로 들어왔다"를 POST /api/partner-touches 로 알린다
|
||||
▼
|
||||
신청: 신청 완료 위젯이 신청 기록에 클릭 ID 를 함께 보낸다 → 바로 귀속
|
||||
구매: 서버가 1시간마다 최근 60일 아임웹 주문(회원 주문)을 훑어 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
|
||||
구매: 구매 기록과 같은 주문 동기화(1시간마다)가 회원 주문의 결제완료 상품을 귀속, 환불·취소는 집계에서 뺀다
|
||||
```
|
||||
|
||||
- **귀속 규칙**: 신청·구매 직전 30일 안에 누른 **그 상품의** 업체 링크 중 마지막 것 (마지막 클릭)
|
||||
|
||||
+177
-86
@@ -421,7 +421,7 @@
|
||||
<span class="mgm-nav-label">운영</span>
|
||||
<button type="button" data-view="applications">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="5" y="4" width="14" height="17" rx="2"/><path d="M9 4V3h6v1"/><path d="m9 13 2 2 4-4"/></svg>
|
||||
신청 내역
|
||||
신청·구매
|
||||
</button>
|
||||
<button type="button" data-view="partners">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
@@ -452,16 +452,16 @@
|
||||
<!-- ========== 개요 ========== -->
|
||||
<section data-panel="overview" class="mgm-stack" hidden>
|
||||
<div class="mgm-kpis">
|
||||
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="today">
|
||||
<span class="mgm-kpi-label">오늘 신청</span>
|
||||
<span class="mgm-kpi-value" data-kpi="app-today">-</span>
|
||||
<span class="mgm-kpi-sub" data-kpi="app-today-sub"> </span>
|
||||
</button>
|
||||
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7">
|
||||
<span class="mgm-kpi-label">최근 7일 신청</span>
|
||||
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7" data-app-kind="application">
|
||||
<span class="mgm-kpi-label">최근 7일 무료 신청</span>
|
||||
<span class="mgm-kpi-value" data-kpi="app-week">-</span>
|
||||
<span class="mgm-kpi-sub" data-kpi="app-week-sub"> </span>
|
||||
</button>
|
||||
<button type="button" class="mgm-card mgm-kpi" data-goto="applications" data-app-preset="7" data-app-kind="purchase">
|
||||
<span class="mgm-kpi-label">최근 7일 유료 구매</span>
|
||||
<span class="mgm-kpi-value" data-kpi="buy-week">-</span>
|
||||
<span class="mgm-kpi-sub" data-kpi="buy-week-sub"> </span>
|
||||
</button>
|
||||
<button type="button" class="mgm-card mgm-kpi" data-goto="partners" data-ptn-preset="7">
|
||||
<span class="mgm-kpi-label">최근 7일 업체 링크 신청</span>
|
||||
<span class="mgm-kpi-value" data-kpi="ptn-week">-</span>
|
||||
@@ -479,7 +479,7 @@
|
||||
<h3>신청 유입 상위</h3>
|
||||
<span class="mgm-muted">최근 30일 · 신청 때 유입 기준</span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30">신청 내역 →</button>
|
||||
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="30" data-app-kind="application">신청·구매 →</button>
|
||||
</div>
|
||||
<div class="mgm-bars" data-role="ov-app-bars"><div class="mgm-muted">불러오는 중…</div></div>
|
||||
</section>
|
||||
@@ -501,13 +501,13 @@
|
||||
<div class="mgm-grid-2 is-wide-left">
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>최근 신청</h3>
|
||||
<h3>최근 신청·구매</h3>
|
||||
<span class="mgm-spacer"></span>
|
||||
<button type="button" class="mgm-link" data-goto="applications" data-app-preset="all">전체 보기 →</button>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th>신청일시</th><th>신청자</th><th>상품</th><th>업체 · 유입</th></tr></thead>
|
||||
<thead><tr><th>일시</th><th>이름</th><th>상품</th><th>업체 · 유입</th></tr></thead>
|
||||
<tbody data-role="ov-recent-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -524,7 +524,7 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ========== 신청 내역 ========== -->
|
||||
<!-- ========== 신청·구매 내역 ========== -->
|
||||
<section data-panel="applications" class="mgm-stack" hidden>
|
||||
<section class="mgm-card">
|
||||
<form data-role="app-filter" class="mgm-toolbar" autocomplete="off">
|
||||
@@ -532,11 +532,16 @@
|
||||
<button type="button" data-app-mode="product" class="is-active">상품별</button>
|
||||
<button type="button" data-app-mode="partner">업체별</button>
|
||||
</div>
|
||||
<div class="mgm-seg" role="group" aria-label="종류">
|
||||
<button type="button" data-app-type="all" class="is-active">전체</button>
|
||||
<button type="button" data-app-type="application">무료 신청</button>
|
||||
<button type="button" data-app-type="purchase">유료 구매</button>
|
||||
</div>
|
||||
<label class="mgm-search">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></svg>
|
||||
<input type="search" name="q" maxlength="100" placeholder="신청자 이름·아이디·이메일·연락처" aria-label="신청자 검색">
|
||||
<input type="search" name="q" maxlength="100" placeholder="이름·아이디·이메일·연락처" aria-label="신청자·구매자 검색">
|
||||
</label>
|
||||
<div class="mgm-seg" role="group" aria-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>
|
||||
@@ -549,19 +554,19 @@
|
||||
<div class="mgm-grid-2 is-wide-left">
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3 data-role="app-groups-title">상품별 신청</h3>
|
||||
<h3 data-role="app-groups-title">상품별 신청·구매</h3>
|
||||
<span class="mgm-muted">행을 누르면 아래 목록과 오른쪽 집계를 그 항목으로 좁힙니다</span>
|
||||
</div>
|
||||
<div class="mgm-table-wrap">
|
||||
<table>
|
||||
<thead><tr><th data-role="app-groups-col">상품</th><th class="mgm-r">신청</th><th class="mgm-r" title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼 클릭</th></tr></thead>
|
||||
<thead><tr><th data-role="app-groups-col">상품</th><th class="mgm-r">무료 신청</th><th class="mgm-r" title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼 클릭</th><th class="mgm-r" title="결제완료된 주문 (환불·취소 제외). 환불·취소된 건은 옆에 따로 적습니다">유료 구매</th></tr></thead>
|
||||
<tbody data-role="app-groups"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청 유입</h3>
|
||||
<h3>유입</h3>
|
||||
<span class="mgm-spacer"></span>
|
||||
<div class="mgm-seg" role="group" aria-label="집계 항목">
|
||||
<button type="button" data-app-group="source" class="is-active">유입원</button>
|
||||
@@ -571,8 +576,8 @@
|
||||
</div>
|
||||
<div class="mgm-row" style="margin-bottom:12px">
|
||||
<div class="mgm-seg" role="group" aria-label="유입 기준">
|
||||
<button type="button" data-app-basis="apply_first" class="is-active" title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관)">신청 때 유입</button>
|
||||
<button type="button" data-app-basis="join" title="신청한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외)">가입 경로</button>
|
||||
<button type="button" data-app-basis="apply" class="is-active" title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 무료 신청에만 있습니다">신청 때 유입</button>
|
||||
<button type="button" data-app-basis="join" title="신청·구매한 회원이 가입할 때 기록된 경로 (가입 후 채워진 값은 제외, 비회원 구매는 값 없음)">가입 경로</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mgm-bars" data-role="app-stats-bars"><div class="mgm-muted">불러오는 중…</div></div>
|
||||
@@ -582,7 +587,7 @@
|
||||
|
||||
<section class="mgm-card">
|
||||
<div class="mgm-card-head">
|
||||
<h3>신청자</h3>
|
||||
<h3>신청·구매한 사람</h3>
|
||||
<span data-role="app-chips" class="mgm-chiprow"></span>
|
||||
<span class="mgm-spacer"></span>
|
||||
<span class="mgm-muted" data-role="app-total"></span>
|
||||
@@ -591,14 +596,14 @@
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>신청일시</th><th>신청자</th><th>연락처</th><th>상품</th><th>업체</th><th title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼</th>
|
||||
<th title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관)">신청 때 유입</th><th>가입 경로</th>
|
||||
<th>일시</th><th>구분</th><th>이름</th><th>연락처</th><th>상품</th><th>업체</th><th title="신청 완료 화면의 오픈카톡 버튼을 누른 수 — 실제 입장 여부는 카카오가 알려주지 않아 알 수 없습니다">카톡 버튼</th>
|
||||
<th title="신청 시점에 브라우저에 남아 있던 유입경로 (UTM 스크립트 2일 보관) — 구매에는 이 기록이 없습니다">신청 때 유입</th><th>가입 경로</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-role="app-rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mgm-empty" data-role="app-empty" hidden><b>신청 기록이 없습니다</b>기간이나 선택한 항목을 바꿔 보세요.</div>
|
||||
<div class="mgm-empty" data-role="app-empty" hidden><b>신청·구매 기록이 없습니다</b>기간이나 종류, 선택한 항목을 바꿔 보세요. 구매는 아임웹 주문을 1시간마다 가져옵니다.</div>
|
||||
<div class="mgm-more"><button type="button" data-action="app-more" hidden>더 보기</button></div>
|
||||
</section>
|
||||
</section>
|
||||
@@ -870,8 +875,8 @@
|
||||
var VIEW_KEY = 'mg_admin_view';
|
||||
|
||||
var VIEWS = {
|
||||
overview: { title: '개요', desc: '신청·업체 실적·가입 현황을 한눈에 봅니다' },
|
||||
applications: { title: '신청 내역', desc: '강의 신청을 상품별·업체별로 보고, 신청자와 유입경로를 확인합니다' },
|
||||
overview: { title: '개요', desc: '신청·구매·업체 실적·가입 현황을 한눈에 봅니다' },
|
||||
applications: { title: '신청·구매 내역', desc: '무료 신청과 유료 구매를 상품별·업체별로 보고, 누가 신청·구매했는지 확인합니다' },
|
||||
partners: { title: '업체 링크', desc: '업체별 링크를 발급하고 클릭·신청·구매 실적을 봅니다' },
|
||||
members: { title: '회원', desc: '가입 기간·회원 그룹·유입경로로 회원을 찾습니다' },
|
||||
integration: { title: '연동 관리', desc: '아임웹 연결과 회원 동기화 상태' }
|
||||
@@ -895,11 +900,12 @@
|
||||
groupCode: '', // 선택한 회원 그룹 (폼 reset 으로 지워지지 않게 상태로 둔다)
|
||||
groups: null, groupsStale: true,
|
||||
memberRows: {}, // 상세 패널용 — 회원 코드 → 회원
|
||||
// 신청 — mode: product | partner, group: 고른 상품번호 / 업체 ID('none' = 업체 없음)
|
||||
// 신청·구매 — mode: product | partner, type: all | application | purchase,
|
||||
// group: 고른 상품번호 / 업체 ID('none' = 업체 없음)
|
||||
app: {
|
||||
range: 'all', mode: 'product', group: null, groupLabel: '',
|
||||
range: 'all', mode: 'product', type: 'all', group: null, groupLabel: '',
|
||||
cursor: null, loading: false, seq: 0, groupsSeq: 0,
|
||||
basis: 'apply_first', statsGroup: 'source', statsSeq: 0, inflowFilter: null,
|
||||
basis: 'apply', statsGroup: 'source', statsSeq: 0, inflowFilter: null,
|
||||
rows: {}
|
||||
},
|
||||
// 업체 링크
|
||||
@@ -1243,7 +1249,7 @@
|
||||
if (!link || !root.contains(link)) return;
|
||||
var target = link.getAttribute('data-goto');
|
||||
if (target === 'applications' && link.hasAttribute('data-app-preset')) {
|
||||
openApplications({ range: link.getAttribute('data-app-preset') });
|
||||
openApplications({ range: link.getAttribute('data-app-preset'), type: link.getAttribute('data-app-kind') || 'all' });
|
||||
return;
|
||||
}
|
||||
if (target === 'partners' && link.hasAttribute('data-ptn-preset')) {
|
||||
@@ -1457,14 +1463,16 @@
|
||||
function loadOverview() {
|
||||
renderOverviewStatus();
|
||||
|
||||
api('GET', '/admin/applications/groups' + toQuery(periodQuery('today', 'appliedFrom', 'appliedTo'))).then(function (g) {
|
||||
setKpi('app-today', num(g.total));
|
||||
setKpi('app-today-sub', '카톡 버튼 클릭 ' + num(g.kakaoTotal) + '명');
|
||||
}).catch(errorNotice);
|
||||
|
||||
api('GET', '/admin/applications/groups' + toQuery(periodQuery('7', 'appliedFrom', 'appliedTo'))).then(function (g) {
|
||||
setKpi('app-week', num(g.total));
|
||||
setKpi('app-week-sub', '카톡 버튼 클릭률 ' + pct(g.kakaoTotal, g.total));
|
||||
// 오늘·최근 7일 신청·구매 수 — 두 요청이 모두 와야 카드 두 장을 함께 채운다
|
||||
Promise.all([
|
||||
api('GET', '/admin/activities/groups' + toQuery(periodQuery('today', 'from', 'to'))),
|
||||
api('GET', '/admin/activities/groups' + toQuery(periodQuery('7', 'from', 'to')))
|
||||
]).then(function (res) {
|
||||
var today = res[0].totals, week = res[1].totals;
|
||||
setKpi('app-week', num(week.applicationCount));
|
||||
setKpi('app-week-sub', '오늘 ' + num(today.applicationCount) + '건 · 카톡 버튼 클릭률 ' + pct(week.kakaoCount, week.applicationCount));
|
||||
setKpi('buy-week', num(week.purchaseCount));
|
||||
setKpi('buy-week-sub', '오늘 ' + num(today.purchaseCount) + '건' + (week.cancelledCount ? ' · 환불·취소 ' + num(week.cancelledCount) + '건 별도' : ' · 환불·취소 제외'));
|
||||
}).catch(errorNotice);
|
||||
|
||||
api('GET', '/admin/partners' + toQuery(Object.assign({ limit: LIST_LIMIT }, periodQuery('7', 'from', 'to')))).then(function (page) {
|
||||
@@ -1478,7 +1486,7 @@
|
||||
setKpi('join-week', num(c.atJoin + c.afterJoin + c.none));
|
||||
}).catch(errorNotice);
|
||||
|
||||
api('GET', '/admin/applications/inflow-stats' + toQuery(Object.assign({ basis: 'apply_first', groupBy: 'source' }, periodQuery('30', 'appliedFrom', 'appliedTo')))).then(function (s) {
|
||||
api('GET', '/admin/activities/inflow-stats' + toQuery(Object.assign({ basis: 'apply', groupBy: 'source' }, periodQuery('30', 'from', 'to')))).then(function (s) {
|
||||
$('[data-role="ov-app-bars"]').innerHTML = barsHtml(s.items.slice(0, TOP_SIZE), true, null, '건');
|
||||
}).catch(errorNotice);
|
||||
|
||||
@@ -1495,18 +1503,17 @@
|
||||
}).join('') : '<tr><td colspan="4" class="mgm-empty">등록된 업체가 없습니다.</td></tr>';
|
||||
}).catch(errorNotice);
|
||||
|
||||
api('GET', '/admin/applications' + toQuery({ limit: RECENT_SIZE })).then(function (page) {
|
||||
api('GET', '/admin/activities' + toQuery({ limit: RECENT_SIZE })).then(function (page) {
|
||||
$('[data-role="ov-recent-rows"]').innerHTML = page.items.length ? page.items.map(function (a) {
|
||||
state.app.rows[a.id] = a;
|
||||
var m = a.member;
|
||||
var inf = a.inflow;
|
||||
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
|
||||
'<td>' + whenCell(a.appliedAt) + '</td>' +
|
||||
'<td>' + personCell(m ? m.name : a.memberUid, '') + '</td>' +
|
||||
'<td class="mgm-wrap">' + dash(a.productName) + '</td>' +
|
||||
'<td>' + whenCell(a.occurredAt) + '</td>' +
|
||||
'<td>' + personCell(activityName(a), '') + '</td>' +
|
||||
'<td class="mgm-wrap">' + typeBadge(a) + ' ' + dash(a.productName) + '</td>' +
|
||||
'<td>' + (a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span>' : (inf && inf.source ? esc(inf.source) : '<span class="mgm-faint">-</span>')) + '</td>' +
|
||||
'</tr>';
|
||||
}).join('') : '<tr><td colspan="4" class="mgm-empty">아직 신청이 없습니다.</td></tr>';
|
||||
}).join('') : '<tr><td colspan="4" class="mgm-empty">아직 신청·구매가 없습니다.</td></tr>';
|
||||
}).catch(errorNotice);
|
||||
|
||||
api('GET', '/admin/members/inflow-stats' + toQuery({ groupBy: 'source' })).then(function (s) {
|
||||
@@ -1517,7 +1524,7 @@
|
||||
$('[data-role="ov-app-bars"]').addEventListener('click', function (e) {
|
||||
var bar = closest(e.target, '.mgm-bar');
|
||||
if (!bar || bar.disabled) return;
|
||||
openApplications({ range: '30', inflowFilter: { basis: 'apply_first', field: 'source', value: bar.getAttribute('data-key') } });
|
||||
openApplications({ range: '30', type: 'application', inflowFilter: { basis: 'apply', field: 'source', value: bar.getAttribute('data-key') } });
|
||||
});
|
||||
$('[data-role="ov-join-bars"]').addEventListener('click', function (e) {
|
||||
var bar = closest(e.target, '.mgm-bar');
|
||||
@@ -1533,31 +1540,57 @@
|
||||
if (row) openApplicationDrawer(row.getAttribute('data-app-row'));
|
||||
});
|
||||
|
||||
/* ===================== 신청 내역 ===================== */
|
||||
/* ===================== 신청·구매 내역 ===================== */
|
||||
var appForm = $('[data-role="app-filter"]');
|
||||
var APP_BASIS_LABEL = { apply_first: '신청 때 유입', join: '가입 경로' };
|
||||
var APP_BASIS_LABEL = { apply: '신청 때 유입', join: '가입 경로' };
|
||||
var APP_GROUP_LABEL = { source: '유입원', medium: '매체', campaign: '캠페인', content: '콘텐츠' };
|
||||
var APP_TYPE_LABEL = { application: '무료 신청', purchase: '유료 구매' };
|
||||
|
||||
// 다른 화면에서 신청 내역 열기 — 기간·유입 필터를 미리 채운다
|
||||
// 구분 칸 — 무료 신청 / 유료 구매 (+ 환불·취소)
|
||||
function typeBadge(a) {
|
||||
if (a.type === 'application') return '<span class="mgm-badge is-ok">무료 신청</span>';
|
||||
return '<span class="mgm-badge is-accent">유료 구매</span>' +
|
||||
(a.isCancelled ? ' <span class="mgm-badge is-danger" title="환불·취소·반품된 주문 — 구매 수에서 빠집니다">환불·취소</span>' : '');
|
||||
}
|
||||
// 누구인지 — 회원이면 회원 이름, 비회원 구매면 주문자 이름
|
||||
function activityName(a) {
|
||||
if (a.member) return a.member.name;
|
||||
if (a.orderer && a.orderer.name) return a.orderer.name;
|
||||
return a.memberUid || '';
|
||||
}
|
||||
function activityPersonCell(a) {
|
||||
if (a.member) return personCell(a.member.name, a.member.uid);
|
||||
if (!a.memberUid && a.orderer) return personCell(a.orderer.name, '비회원');
|
||||
return personCell(a.memberUid, '회원 정보 없음');
|
||||
}
|
||||
function activityPhone(a) {
|
||||
if (a.member && a.member.callnum) return a.member.callnum;
|
||||
return a.orderer ? a.orderer.phone : '';
|
||||
}
|
||||
|
||||
// 다른 화면에서 신청·구매 내역 열기 — 기간·종류·유입 필터를 미리 채운다
|
||||
function openApplications(opts) {
|
||||
var app = state.app;
|
||||
app.range = opts.range || 'all';
|
||||
app.type = opts.type || 'all';
|
||||
app.group = null; app.groupLabel = '';
|
||||
app.inflowFilter = opts.inflowFilter || null;
|
||||
setSeg('[data-app-range]', 'data-app-range', app.range);
|
||||
if (app.inflowFilter) app.basis = app.inflowFilter.basis;
|
||||
go('applications');
|
||||
}
|
||||
|
||||
function loadApplicationsView() {
|
||||
setSeg('[data-app-mode]', 'data-app-mode', state.app.mode);
|
||||
setSeg('[data-app-range]', 'data-app-range', state.app.range);
|
||||
setSeg('[data-app-type]', 'data-app-type', state.app.type);
|
||||
loadAppGroups();
|
||||
loadApplications(true);
|
||||
}
|
||||
|
||||
// 목록·집계 공통 — 기간 + 고른 상품/업체
|
||||
// 목록·집계 공통 — 기간 + 종류 + 고른 상품/업체
|
||||
function appScope() {
|
||||
var app = state.app, q = periodQuery(app.range, 'appliedFrom', 'appliedTo');
|
||||
var app = state.app, q = periodQuery(app.range, 'from', 'to');
|
||||
if (app.type !== 'all') q.type = app.type;
|
||||
if (app.group !== null) {
|
||||
if (app.mode === 'product') q.productIdx = app.group;
|
||||
else q.partnerId = app.group;
|
||||
@@ -1573,45 +1606,65 @@
|
||||
return q;
|
||||
}
|
||||
|
||||
// 상품별·업체별 요약 표
|
||||
// 상품별·업체별 요약 표 — 종류와 무관하게 신청·구매 수를 함께 보여준다
|
||||
function loadAppGroups() {
|
||||
var app = state.app;
|
||||
var seq = ++app.groupsSeq;
|
||||
var isPartner = app.mode === 'partner';
|
||||
$('[data-role="app-groups-title"]').textContent = isPartner ? '업체별 신청' : '상품별 신청';
|
||||
$('[data-role="app-groups-title"]').textContent = isPartner ? '업체별 신청·구매' : '상품별 신청·구매';
|
||||
$('[data-role="app-groups-col"]').textContent = isPartner ? '업체' : '상품';
|
||||
api('GET', '/admin/applications/groups' + toQuery(Object.assign({ by: app.mode }, periodQuery(app.range, 'appliedFrom', 'appliedTo')))).then(function (g) {
|
||||
api('GET', '/admin/activities/groups' + toQuery(Object.assign({ by: app.mode }, periodQuery(app.range, 'from', 'to')))).then(function (g) {
|
||||
if (seq !== app.groupsSeq) return;
|
||||
var rows = [{ group: null, label: isPartner ? '전체 신청' : '전체 상품', count: g.total, kakao: g.kakaoTotal, isAll: true }]
|
||||
var rows = [Object.assign({ group: null, label: isPartner ? '전체' : '전체 상품', isAll: true }, g.totals)]
|
||||
.concat(g.items.map(function (i) {
|
||||
return {
|
||||
// 화면용 값이 API 값(label 등)을 덮어쓰도록 뒤에 둔다
|
||||
return Object.assign({}, i, {
|
||||
group: i.key === null ? 'none' : i.key,
|
||||
label: i.key === null ? '업체 링크 없이 신청' : (i.label || (isPartner ? '(삭제된 업체)' : '상품 #' + i.key)),
|
||||
label: i.key === null ? '업체 링크 없이' : (i.label || (isPartner ? '(삭제된 업체)' : '상품 #' + i.key)),
|
||||
sub: !isPartner && i.key ? '#' + i.key : (i.key === null ? '직접·광고 등 업체 링크를 거치지 않음' : ''),
|
||||
count: i.count, kakao: i.kakaoCount, isNone: i.key === null
|
||||
};
|
||||
isNone: i.key === null
|
||||
});
|
||||
}));
|
||||
$('[data-role="app-groups"]').innerHTML = rows.map(function (r) {
|
||||
var selected = r.group === app.group;
|
||||
return '<tr class="is-clickable' + (selected ? ' is-selected' : '') + '" data-app-groupkey="' + esc(r.group === null ? '' : r.group) + '" data-label="' + esc(r.label) + '">' +
|
||||
'<td class="mgm-wrap"><span class="mgm-cell-main"' + (r.isNone ? ' style="font-weight:500;color:var(--mgm-muted)"' : '') + '>' + esc(r.label) + '</span>' +
|
||||
(r.sub ? '<span class="mgm-cell-sub">' + esc(r.sub) + '</span>' : '') + '</td>' +
|
||||
'<td class="mgm-r"><b>' + num(r.count) + '</b></td>' +
|
||||
'<td class="mgm-r">' + num(r.kakao) + '<span class="mgm-rate">' + pct(r.kakao, r.count) + '</span></td></tr>';
|
||||
'<td class="mgm-r">' + countCell(r.applicationCount) + '</td>' +
|
||||
'<td class="mgm-r">' + (r.applicationCount ? num(r.kakaoCount) + '<span class="mgm-rate">' + pct(r.kakaoCount, r.applicationCount) + '</span>' : '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td class="mgm-r">' + countCell(r.purchaseCount) +
|
||||
(r.cancelledCount ? '<span class="mgm-rate" title="환불·취소된 구매">환불 ' + num(r.cancelledCount) + '</span>' : '') + '</td></tr>';
|
||||
}).join('');
|
||||
}).catch(function (err) { if (seq === app.groupsSeq) errorNotice(err); });
|
||||
}
|
||||
function countCell(n) { return n ? '<b>' + num(n) + '</b>' : '<span class="mgm-faint">0</span>'; }
|
||||
|
||||
// 유입 집계 — 구매에는 '신청 때 유입'이 없으므로 유료 구매만 볼 때는 가입 경로로 고정한다
|
||||
function syncBasisButtons() {
|
||||
var app = state.app, onlyPurchase = app.type === 'purchase';
|
||||
if (onlyPurchase && app.basis === 'apply') app.basis = 'join';
|
||||
each($all('[data-app-basis]'), function (b) {
|
||||
var isApply = b.getAttribute('data-app-basis') === 'apply';
|
||||
b.disabled = onlyPurchase && isApply;
|
||||
});
|
||||
setSeg('[data-app-basis]', 'data-app-basis', app.basis);
|
||||
}
|
||||
|
||||
function loadAppStats() {
|
||||
var app = state.app;
|
||||
var seq = ++app.statsSeq;
|
||||
syncBasisButtons();
|
||||
var q = Object.assign({ basis: app.basis, groupBy: app.statsGroup }, appScope());
|
||||
api('GET', '/admin/applications/inflow-stats' + toQuery(q)).then(function (s) {
|
||||
api('GET', '/admin/activities/inflow-stats' + toQuery(q)).then(function (s) {
|
||||
if (seq !== app.statsSeq) return;
|
||||
var inf = app.inflowFilter;
|
||||
var selected = inf && inf.basis === app.basis && inf.field === app.statsGroup ? inf.value : null;
|
||||
$('[data-role="app-stats-bars"]').innerHTML = barsHtml(s.items.slice(0, 10), true, selected, '건');
|
||||
$('[data-role="app-stats-desc"]').textContent = '신청 ' + num(s.total) + '건 · ' + APP_BASIS_LABEL[s.basis] + ' 기준 · 막대를 누르면 목록을 거릅니다';
|
||||
var counted = [];
|
||||
if (app.type !== 'purchase') counted.push('무료 신청 ' + num(s.applicationTotal) + '건');
|
||||
if (s.basis === 'join' && app.type !== 'application') counted.push('유료 구매 ' + num(s.purchaseTotal) + '건');
|
||||
$('[data-role="app-stats-desc"]').textContent = counted.join(' · ') + ' · ' + APP_BASIS_LABEL[s.basis] + ' 기준' +
|
||||
(s.basis === 'apply' && app.type === 'all' ? ' (구매는 가입 경로로만 볼 수 있습니다)' : '') + ' · 막대를 누르면 목록을 거릅니다';
|
||||
}).catch(function (err) { if (seq === app.statsSeq) errorNotice(err); });
|
||||
}
|
||||
|
||||
@@ -1630,19 +1683,20 @@
|
||||
}
|
||||
|
||||
function appRowHtml(a) {
|
||||
var m = a.member;
|
||||
var isApp = a.type === 'application';
|
||||
state.app.rows[a.id] = a;
|
||||
return '<tr class="is-clickable" data-app-row="' + esc(a.id) + '">' +
|
||||
'<td>' + whenCell(a.appliedAt) + '</td>' +
|
||||
'<td>' + (m ? personCell(m.name, m.uid) : personCell(a.memberUid, '회원 정보 없음')) + '</td>' +
|
||||
'<td>' + dash(phone(m && m.callnum)) + '</td>' +
|
||||
'<td>' + whenCell(a.occurredAt) + '</td>' +
|
||||
'<td>' + typeBadge(a) + '</td>' +
|
||||
'<td>' + activityPersonCell(a) + '</td>' +
|
||||
'<td>' + dash(phone(activityPhone(a))) + '</td>' +
|
||||
'<td class="mgm-wrap">' + dash(a.productName) + '<span class="mgm-cell-sub">#' + esc(a.productIdx) + '</span></td>' +
|
||||
'<td>' + (a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span>' : '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td>' + (a.kakaoClickedAt
|
||||
'<td>' + (isApp && a.kakaoClickedAt
|
||||
? '<span class="mgm-badge is-ok" title="' + esc(kstDateTime(a.kakaoClickedAt)) + ' 에 버튼을 눌렀습니다 (실제 입장 여부는 알 수 없음)">클릭</span>'
|
||||
: '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + inflowText(a.inflow) + '</td>' +
|
||||
'<td class="mgm-wrap">' + joinInflowCell(m) + '</td>' +
|
||||
'<td class="mgm-wrap">' + (isApp ? inflowText(a.inflow) : '<span class="mgm-faint">-</span>') + '</td>' +
|
||||
'<td class="mgm-wrap">' + (!a.member && !a.memberUid ? '<span class="mgm-faint">비회원</span>' : joinInflowCell(a.member)) + '</td>' +
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
@@ -1661,7 +1715,7 @@
|
||||
var moreBtn = $('[data-action="app-more"]');
|
||||
moreBtn.disabled = true;
|
||||
|
||||
api('GET', '/admin/applications' + toQuery(q)).then(function (page) {
|
||||
api('GET', '/admin/activities' + toQuery(q)).then(function (page) {
|
||||
if (seq !== app.seq) return;
|
||||
var tbody = $('[data-role="app-rows"]');
|
||||
var html = page.items.map(appRowHtml).join('');
|
||||
@@ -1681,23 +1735,47 @@
|
||||
});
|
||||
}
|
||||
|
||||
function partnerText(a) {
|
||||
return a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span> 업체 링크 실적' : '업체 링크를 거치지 않음';
|
||||
}
|
||||
|
||||
function openApplicationDrawer(id) {
|
||||
var a = state.app.rows[id];
|
||||
if (!a) return;
|
||||
var inf = a.inflow || {};
|
||||
var html = '<section class="mgm-dsec"><h4>신청</h4>' + dlHtml([
|
||||
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
|
||||
['신청일시', esc(kstDateTime(a.appliedAt))],
|
||||
['업체', a.partner ? '<span class="mgm-badge is-accent">' + esc(a.partner.name) + '</span> 업체 링크 실적' : '업체 링크를 거치지 않음'],
|
||||
['카톡 버튼', a.kakaoClickedAt ? '눌렀음 · ' + esc(kstDateTime(a.kakaoClickedAt)) + ' <span class="mgm-faint">(실제 입장 여부는 알 수 없음)</span>' : '누르지 않음']
|
||||
]) + '</section>' +
|
||||
'<section class="mgm-dsec"><h4>신청 때 유입</h4>' + dlHtml([
|
||||
['유입원', esc(inf.source)],
|
||||
['매체', esc(inf.medium)],
|
||||
['캠페인', esc(inf.campaign)],
|
||||
['콘텐츠', esc(inf.content)]
|
||||
]) + '</section>' + memberSectionsHtml(a.member);
|
||||
openDrawer((a.member ? a.member.name : a.memberUid) + ' 님의 신청', html);
|
||||
var html;
|
||||
if (a.type === 'application') {
|
||||
var inf = a.inflow || {};
|
||||
html = '<section class="mgm-dsec"><h4>무료 신청</h4>' + dlHtml([
|
||||
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
|
||||
['신청일시', esc(kstDateTime(a.occurredAt))],
|
||||
['업체', partnerText(a)],
|
||||
['카톡 버튼', a.kakaoClickedAt ? '눌렀음 · ' + esc(kstDateTime(a.kakaoClickedAt)) + ' <span class="mgm-faint">(실제 입장 여부는 알 수 없음)</span>' : '누르지 않음']
|
||||
]) + '</section>' +
|
||||
'<section class="mgm-dsec"><h4>신청 때 유입</h4>' + dlHtml([
|
||||
['유입원', esc(inf.source)],
|
||||
['매체', esc(inf.medium)],
|
||||
['캠페인', esc(inf.campaign)],
|
||||
['콘텐츠', esc(inf.content)]
|
||||
]) + '</section>' + memberSectionsHtml(a.member);
|
||||
} else {
|
||||
var o = a.orderer || {};
|
||||
var isGuest = !a.member && !a.memberUid;
|
||||
html = '<section class="mgm-dsec"><h4>유료 구매' + (a.isCancelled ? ' <span class="mgm-badge is-danger">환불·취소</span>' : '') + '</h4>' + dlHtml([
|
||||
['상품', dash(a.productName) + ' <span class="mgm-faint">#' + esc(a.productIdx) + '</span>'],
|
||||
['주문일시', esc(kstDateTime(a.occurredAt))],
|
||||
['주문번호', esc(a.orderNo)],
|
||||
['상태', a.isCancelled ? '환불·취소됨 <span class="mgm-faint">(구매 수에서 빠집니다)</span>' : '결제완료'],
|
||||
['업체', partnerText(a)]
|
||||
]) + '</section>' +
|
||||
'<section class="mgm-dsec"><h4>주문자</h4>' + dlHtml([
|
||||
['이름', dash(o.name)],
|
||||
['이메일', dash(o.email)],
|
||||
['연락처', dash(phone(o.phone))],
|
||||
['회원 여부', isGuest ? '비회원 주문' : '회원 주문']
|
||||
]) + '</section>' +
|
||||
(isGuest ? '' : memberSectionsHtml(a.member));
|
||||
}
|
||||
openDrawer((activityName(a) || '알 수 없음') + ' 님의 ' + APP_TYPE_LABEL[a.type], html);
|
||||
}
|
||||
|
||||
// 보기 방식 (상품별 / 업체별)
|
||||
@@ -1712,6 +1790,19 @@
|
||||
loadApplications(true);
|
||||
});
|
||||
});
|
||||
// 종류 (전체 / 무료 신청 / 유료 구매) — 요약 표는 그대로 두고 목록·유입 집계만 거른다
|
||||
each($all('[data-app-type]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
var type = b.getAttribute('data-app-type');
|
||||
if (type === state.app.type) return;
|
||||
state.app.type = type;
|
||||
// 구매에는 신청 때 유입이 없으므로 그 기준의 유입 필터는 풀어 준다
|
||||
var inf = state.app.inflowFilter;
|
||||
if (type === 'purchase' && inf && inf.basis === 'apply') state.app.inflowFilter = null;
|
||||
setSeg('[data-app-type]', 'data-app-type', type);
|
||||
loadApplications(true);
|
||||
});
|
||||
});
|
||||
each($all('[data-app-range]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
state.app.range = b.getAttribute('data-app-range');
|
||||
@@ -1735,8 +1826,8 @@
|
||||
});
|
||||
each($all('[data-app-basis]'), function (b) {
|
||||
b.addEventListener('click', function () {
|
||||
if (b.disabled) return;
|
||||
state.app.basis = b.getAttribute('data-app-basis');
|
||||
setSeg('[data-app-basis]', 'data-app-basis', state.app.basis);
|
||||
loadAppStats();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user