425 lines
22 KiB
PHP
425 lines
22 KiB
PHP
@extends('admin.layouts.app')
|
|
|
|
@section('title', '마케팅 회원 추출')
|
|
@section('page_title', '마케팅 회원 추출')
|
|
@section('page_desc', '조건에 맞는 회원을 빠르게 조회하고, 전체를 ZIP(암호)로 내려받습니다. (화면은 상위 10건만 표시)')
|
|
|
|
@push('head')
|
|
<style>
|
|
.lbtn{padding:8px 12px;font-size:13px;border-radius:12px;line-height:1.1;text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:6px;
|
|
border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.06);color:inherit;cursor:pointer;}
|
|
.lbtn:hover{background:rgba(255,255,255,.10);text-decoration:none;}
|
|
.lbtn--primary{background:rgba(59,130,246,.88);border-color:rgba(59,130,246,.95);color:#fff;}
|
|
.lbtn--primary:hover{background:rgba(59,130,246,.98);}
|
|
.lbtn--sm{padding:7px 10px;font-size:12px;border-radius:11px;}
|
|
|
|
.mono{padding:4px 8px;border-radius:10px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.10);
|
|
font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:12px;}
|
|
|
|
.kvgrid{display:grid;grid-template-columns:1fr;gap:12px;}
|
|
@media (min-width: 980px){ .kvgrid{grid-template-columns:1fr 1fr 1fr;} }
|
|
.kv{padding:14px;border-radius:16px;border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.04);}
|
|
.kv .k{font-size:12px;opacity:.8;margin-bottom:6px;}
|
|
.kv .v{font-weight:900;}
|
|
|
|
.grid2{display:grid;grid-template-columns:1fr;gap:12px;}
|
|
@media (min-width: 980px){ .grid2{grid-template-columns:1.15fr .85fr;} }
|
|
|
|
.warnbox{border:1px solid rgba(245,158,11,.35);background:rgba(245,158,11,.10);border-radius:16px;padding:12px;}
|
|
.warnbox b{font-weight:900;}
|
|
|
|
/* radio chips */
|
|
.chipset{display:flex;flex-wrap:wrap;gap:8px;}
|
|
.chip{position:relative;}
|
|
.chip input{position:absolute;opacity:0;pointer-events:none;}
|
|
.chip label{
|
|
display:inline-flex;align-items:center;gap:6px;
|
|
padding:7px 10px;border-radius:999px;font-size:12px;cursor:pointer;
|
|
border:1px solid rgba(255,255,255,.12);
|
|
background:rgba(255,255,255,.05);
|
|
}
|
|
.chip input:checked + label{
|
|
border-color:rgba(59,130,246,.75);
|
|
background:rgba(59,130,246,.18);
|
|
}
|
|
|
|
details.optbox{border:1px solid rgba(255,255,255,.10);background:rgba(255,255,255,.03);border-radius:14px;padding:10px;}
|
|
details.optbox summary{cursor:pointer; font-weight:900; list-style:none;}
|
|
details.optbox summary::-webkit-details-marker{display:none;}
|
|
|
|
.help{font-size:12px;opacity:.85;margin-top:6px;}
|
|
</style>
|
|
@endpush
|
|
|
|
@section('content')
|
|
@php
|
|
$f = $filters ?? [];
|
|
|
|
$stat3 = (string)($f['stat_3'] ?? '');
|
|
|
|
$regFrom = (string)($f['reg_from'] ?? '');
|
|
$regTo = (string)($f['reg_to'] ?? '');
|
|
|
|
// 로그인 필터: mode + days (둘 중 하나만)
|
|
$loginMode = (string)($f['login_mode'] ?? 'none'); // none|inactive|recent
|
|
$loginDays = (string)($f['login_days'] ?? '');
|
|
|
|
// 구매/최근구매
|
|
$hasPur = (string)($f['has_purchase'] ?? 'all'); // all|1|0
|
|
$recent = (string)($f['recent_purchase'] ?? 'all'); // all|30|90
|
|
$minCnt = (string)($f['min_purchase_count'] ?? '');
|
|
$minAmt = (string)($f['min_purchase_amount'] ?? '');
|
|
|
|
// 수신동의/정보유무
|
|
$optSms = (string)($f['optin_sms'] ?? 'all'); // all|1|0
|
|
$optEmail = (string)($f['optin_email'] ?? 'all'); // all|1|0
|
|
$hasPhone = (string)($f['has_phone'] ?? 'all'); // all|1|0
|
|
$hasEmail = (string)($f['has_email'] ?? 'all'); // all|1|0
|
|
@endphp
|
|
|
|
<div class="kvgrid" style="margin-bottom:16px;">
|
|
<div class="kv">
|
|
<div class="k">조건 매칭 건수</div>
|
|
<div class="v">{{ number_format((int)($total ?? 0)) }} 건</div>
|
|
</div>
|
|
<div class="kv">
|
|
<div class="k">화면 표시</div>
|
|
<div class="v">상위 10건만 미리보기</div>
|
|
</div>
|
|
<div class="kv">
|
|
<div class="k">통계 기준일</div>
|
|
<div class="v"><span class="mono">{{ $as_of_date ?? '-' }}</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid2" style="margin-bottom:16px;">
|
|
{{-- 검색/필터 --}}
|
|
<form method="GET" action="{{ route('admin.marketing.index') }}">
|
|
<div class="a-card" style="padding:16px;">
|
|
<div style="font-weight:900; margin-bottom:12px;">검색/필터</div>
|
|
|
|
{{-- stat_3 --}}
|
|
<div class="a-field" style="margin-bottom:12px;">
|
|
<label class="a-label">회원상태 (stat_3)</label>
|
|
<div class="chipset">
|
|
<span class="chip">
|
|
<input id="s3_all" type="radio" name="stat_3" value="" {{ $stat3==='' ? 'checked' : '' }}>
|
|
<label for="s3_all">전체</label>
|
|
</span>
|
|
@foreach(($stat3Map ?? []) as $k=>$label)
|
|
<span class="chip">
|
|
<input id="s3_{{ $k }}" type="radio" name="stat_3" value="{{ $k }}" {{ $stat3===(string)$k ? 'checked' : '' }}>
|
|
<label for="s3_{{ $k }}">{{ $label }}</label>
|
|
</span>
|
|
@endforeach
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 가입일 --}}
|
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:12px;">
|
|
<div class="a-field">
|
|
<label class="a-label">가입일 시작</label>
|
|
<input class="a-input" type="date" name="reg_from" value="{{ $regFrom }}" data-date>
|
|
</div>
|
|
<div class="a-field">
|
|
<label class="a-label">가입일 종료</label>
|
|
<input class="a-input" type="date" name="reg_to" value="{{ $regTo }}" data-date>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 로그인 필터 (둘 중 하나만) --}}
|
|
<div class="a-field" style="margin-bottom:12px;">
|
|
<label class="a-label">로그인 기준 필터 (1개만 선택)</label>
|
|
|
|
<div class="chipset" style="margin-bottom:10px;">
|
|
<span class="chip">
|
|
<input id="lm_none" type="radio" name="login_mode" value="none" {{ $loginMode==='none' ? 'checked' : '' }}>
|
|
<label for="lm_none">사용 안함</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="lm_inactive" type="radio" name="login_mode" value="inactive" {{ $loginMode==='inactive' ? 'checked' : '' }}>
|
|
<label for="lm_inactive">미접속일수(이상)</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="lm_recent" type="radio" name="login_mode" value="recent" {{ $loginMode==='recent' ? 'checked' : '' }}>
|
|
<label for="lm_recent">최근 로그인(이내)</label>
|
|
</span>
|
|
</div>
|
|
|
|
<input class="a-input" type="number" name="login_days" min="0" value="{{ $loginDays }}"
|
|
placeholder="예: 30" id="login_days_input">
|
|
<div class="help">
|
|
- <b>미접속일수(이상)</b>: 최근 로그인으로부터 N일 이상 지난 회원<br>
|
|
- <b>최근 로그인(이내)</b>: 최근 로그인으로부터 N일 이내인 회원 (예: 30 → 최근 30일 내 로그인)
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 구매 조건 --}}
|
|
<div class="a-field" style="margin-bottom:12px;">
|
|
<label class="a-label">구매 여부</label>
|
|
<div class="chipset" style="margin-bottom:10px;">
|
|
<span class="chip">
|
|
<input id="hp_all" type="radio" name="has_purchase" value="all" {{ $hasPur==='all' ? 'checked' : '' }}>
|
|
<label for="hp_all">전체</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="hp_1" type="radio" name="has_purchase" value="1" {{ $hasPur==='1' ? 'checked' : '' }}>
|
|
<label for="hp_1">구매 있음</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="hp_0" type="radio" name="has_purchase" value="0" {{ $hasPur==='0' ? 'checked' : '' }}>
|
|
<label for="hp_0">구매 없음</label>
|
|
</span>
|
|
</div>
|
|
|
|
<label class="a-label">최근 구매</label>
|
|
<div class="chipset" style="margin-bottom:10px;">
|
|
<span class="chip">
|
|
<input id="rp_all" type="radio" name="recent_purchase" value="all" {{ $recent==='all' ? 'checked' : '' }}>
|
|
<label for="rp_all">전체</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="rp_30" type="radio" name="recent_purchase" value="30" {{ $recent==='30' ? 'checked' : '' }}>
|
|
<label for="rp_30">최근 30일 구매</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="rp_90" type="radio" name="recent_purchase" value="90" {{ $recent==='90' ? 'checked' : '' }}>
|
|
<label for="rp_90">최근 90일 구매</label>
|
|
</span>
|
|
</div>
|
|
|
|
<details class="optbox" style="margin-bottom:0;">
|
|
<summary>구매 상세 조건(선택)</summary>
|
|
<div style="display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-top:10px;">
|
|
<div>
|
|
<label class="a-label">최소 누적구매횟수</label>
|
|
<input class="a-input" type="number" name="min_purchase_count" min="0" value="{{ $minCnt }}" placeholder="예: 1">
|
|
</div>
|
|
<div>
|
|
<label class="a-label">최소 누적구매금액</label>
|
|
<input class="a-input" type="number" name="min_purchase_amount" min="0" value="{{ $minAmt }}" placeholder="예: 10000">
|
|
</div>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
|
|
{{-- 추가 옵션 --}}
|
|
<details class="optbox" style="margin-bottom:12px;">
|
|
<summary>추가 옵션 (수신동의/정보유무)</summary>
|
|
|
|
<div style="margin-top:10px;">
|
|
<label class="a-label">SMS 수신동의</label>
|
|
<div class="chipset">
|
|
<span class="chip">
|
|
<input id="os_all" type="radio" name="optin_sms" value="all" {{ $optSms==='all' ? 'checked' : '' }}>
|
|
<label for="os_all">전체</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="os_1" type="radio" name="optin_sms" value="1" {{ $optSms==='1' ? 'checked' : '' }}>
|
|
<label for="os_1">수신동의</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="os_0" type="radio" name="optin_sms" value="0" {{ $optSms==='0' ? 'checked' : '' }}>
|
|
<label for="os_0">수신미동의</label>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:10px;">
|
|
<label class="a-label">이메일 수신동의</label>
|
|
<div class="chipset">
|
|
<span class="chip">
|
|
<input id="oe_all" type="radio" name="optin_email" value="all" {{ $optEmail==='all' ? 'checked' : '' }}>
|
|
<label for="oe_all">전체</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="oe_1" type="radio" name="optin_email" value="1" {{ $optEmail==='1' ? 'checked' : '' }}>
|
|
<label for="oe_1">수신동의</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="oe_0" type="radio" name="optin_email" value="0" {{ $optEmail==='0' ? 'checked' : '' }}>
|
|
<label for="oe_0">수신미동의</label>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:10px;">
|
|
<label class="a-label">전화번호 있음</label>
|
|
<div class="chipset">
|
|
<span class="chip">
|
|
<input id="hp2_all" type="radio" name="has_phone" value="all" {{ $hasPhone==='all' ? 'checked' : '' }}>
|
|
<label for="hp2_all">전체</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="hp2_1" type="radio" name="has_phone" value="1" {{ $hasPhone==='1' ? 'checked' : '' }}>
|
|
<label for="hp2_1">있음</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="hp2_0" type="radio" name="has_phone" value="0" {{ $hasPhone==='0' ? 'checked' : '' }}>
|
|
<label for="hp2_0">없음</label>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:10px;">
|
|
<label class="a-label">이메일 있음</label>
|
|
<div class="chipset">
|
|
<span class="chip">
|
|
<input id="he2_all" type="radio" name="has_email" value="all" {{ $hasEmail==='all' ? 'checked' : '' }}>
|
|
<label for="he2_all">전체</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="he2_1" type="radio" name="has_email" value="1" {{ $hasEmail==='1' ? 'checked' : '' }}>
|
|
<label for="he2_1">있음</label>
|
|
</span>
|
|
<span class="chip">
|
|
<input id="he2_0" type="radio" name="has_email" value="0" {{ $hasEmail==='0' ? 'checked' : '' }}>
|
|
<label for="he2_0">없음</label>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
<div style="display:flex; gap:8px; flex-wrap:wrap;">
|
|
<button class="lbtn lbtn--primary" type="submit">조회</button>
|
|
<a class="lbtn" href="{{ route('admin.marketing.index') }}">초기화</a>
|
|
</div>
|
|
|
|
<div class="warnbox" style="margin-top:12px;">
|
|
<b>안내:</b> 화면은 상위 10건만 표시합니다. 다운로드는 조건에 매칭되는 전체 데이터가 포함됩니다.
|
|
</div>
|
|
</div>
|
|
</form>
|
|
|
|
{{-- 다운로드 --}}
|
|
<div class="a-card" style="padding:16px;">
|
|
<div style="font-weight:900; margin-bottom:10px;">전체 다운로드 (ZIP 암호화)</div>
|
|
|
|
<div class="warnbox" style="margin-bottom:12px;">
|
|
- 다운로드 파일에는 <b>성명/전화번호/이메일</b>이 포함됩니다.<br>
|
|
- ZIP 비밀번호는 서버에 저장하지 않습니다. 분실 주의
|
|
</div>
|
|
|
|
<form method="POST" action="{{ route('admin.marketing.export') }}">
|
|
@csrf
|
|
|
|
{{-- filters hidden --}}
|
|
@foreach(($filters ?? []) as $k=>$v)
|
|
@if(is_scalar($v) && $k !== 'phone_enc')
|
|
<input type="hidden" name="{{ $k }}" value="{{ $v }}">
|
|
@endif
|
|
@endforeach
|
|
|
|
<div class="a-field" style="margin-bottom:12px;">
|
|
<label class="a-label">ZIP 비밀번호(필수)</label>
|
|
<input class="a-input" type="password" name="zip_password" required minlength="4" maxlength="64"
|
|
placeholder="예: 1234 또는 강력한 비밀번호">
|
|
@error('zip_password') <div style="color:#ff4d4f;margin-top:6px;">{{ $message }}</div> @enderror
|
|
</div>
|
|
|
|
<button class="lbtn lbtn--primary" type="submit">
|
|
전체 다운로드 ({{ number_format((int)($total ?? 0)) }}건)
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{{-- 미리보기 --}}
|
|
<div class="a-card" style="padding:16px;">
|
|
<div style="display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap;">
|
|
<div style="font-weight:900;">미리보기 (상위 10건)</div>
|
|
<div class="a-muted" style="font-size:12px;">총 {{ number_format((int)($total ?? 0)) }}건 중 상위 10건</div>
|
|
</div>
|
|
|
|
<div style="margin-top:12px; overflow:auto;">
|
|
<table class="a-table" style="width:100%; min-width:1200px;">
|
|
<thead>
|
|
<tr>
|
|
<th style="width:90px;">회원번호</th>
|
|
<th style="width:140px;">성명</th>
|
|
<th style="width:150px;">전화번호</th>
|
|
<th style="width:240px;">이메일</th>
|
|
<th style="width:220px;">회원상태</th>
|
|
<th style="width:150px;">가입일시</th>
|
|
<th style="width:150px;">최근로그인</th>
|
|
<th style="width:110px;">미접속일수</th>
|
|
<th style="width:110px;">누적구매횟수</th>
|
|
<th style="width:140px;">누적구매금액</th>
|
|
<th style="width:150px;">최근구매일</th>
|
|
<th style="width:110px;">SMS수신</th>
|
|
<th style="width:110px;">이메일수신</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
@forelse(($rows ?? []) as $r)
|
|
@php
|
|
$no = (int)($r['mem_no'] ?? 0);
|
|
$inactiveDays = $r['days_since_login'];
|
|
$inactiveDays = ($inactiveDays === null ? '-' : (string)$inactiveDays);
|
|
@endphp
|
|
<tr>
|
|
<td><a class="mono" href="{{ route('admin.members.show', ['memNo'=>$no]) }}">#{{ $no }}</a></td>
|
|
<td>{{ $r['name'] ?? '-' }}</td>
|
|
<td><span class="mono">{{ $r['phone_display'] ?? '-' }}</span></td>
|
|
<td><span class="mono">{{ $r['email'] ?? '' }}</span></td>
|
|
<td><span class="mono">{{ $r['stat_3_label'] ?? ($r['stat_3'] ?? '') }}</span></td>
|
|
<td class="a-muted">{{ $r['reg_at'] ?? '-' }}</td>
|
|
<td class="a-muted">{{ $r['last_login_at'] ?? '-' }}</td>
|
|
<td class="a-muted">{{ $inactiveDays }}</td>
|
|
<td class="a-muted">{{ number_format((int)($r['purchase_count_total'] ?? 0)) }}</td>
|
|
<td class="a-muted">{{ number_format((int)($r['purchase_amount_total'] ?? 0)) }}</td>
|
|
<td class="a-muted">{{ $r['last_purchase_at'] ?? '-' }}</td>
|
|
<td class="a-muted">{{ ((int)($r['optin_sms'] ?? 0)===1) ? '수신동의' : '수신미동의' }}</td>
|
|
<td class="a-muted">{{ ((int)($r['optin_email'] ?? 0)===1) ? '수신동의' : '수신미동의' }}</td>
|
|
</tr>
|
|
@empty
|
|
<tr><td colspan="13" class="a-muted" style="padding:12px;">조건에 해당하는 데이터가 없습니다.</td></tr>
|
|
@endforelse
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
@push('scripts')
|
|
<script>
|
|
// date input 클릭/포커스 시 달력 즉시 표시 (Chrome/Edge 지원)
|
|
document.querySelectorAll('input[type="date"][data-date]').forEach(function (el) {
|
|
const tryShowPicker = function (ev) {
|
|
// isTrusted: 실제 사용자 입력만 허용
|
|
if (ev && ev.isTrusted === false) return;
|
|
|
|
if (typeof el.showPicker === 'function') {
|
|
try {
|
|
el.showPicker();
|
|
} catch (e) {
|
|
// 일부 브라우저/상황에서 막히면 조용히 무시 (기본 동작에 맡김)
|
|
}
|
|
}
|
|
};
|
|
|
|
// focus는 user gesture로 인정 안 되는 케이스가 많아서 제거
|
|
el.addEventListener('pointerdown', tryShowPicker);
|
|
el.addEventListener('mousedown', tryShowPicker); // pointerdown 미지원 대비
|
|
el.addEventListener('click', tryShowPicker); // 마지막 보루
|
|
});
|
|
|
|
// 로그인 필터: mode에 따라 숫자 입력 활성/비활성
|
|
const modeEls = document.querySelectorAll('input[name="login_mode"]');
|
|
const daysEl = document.getElementById('login_days_input');
|
|
|
|
function applyLoginMode() {
|
|
const mode = document.querySelector('input[name="login_mode"]:checked')?.value || 'none';
|
|
if (mode === 'none') {
|
|
daysEl.value = '';
|
|
daysEl.setAttribute('disabled', 'disabled');
|
|
} else {
|
|
daysEl.removeAttribute('disabled');
|
|
daysEl.focus();
|
|
}
|
|
}
|
|
modeEls.forEach(r => r.addEventListener('change', applyLoginMode));
|
|
applyLoginMode();
|
|
</script>
|
|
@endpush
|
|
@endsection
|