- /admin/products: 전체 제품 검색 페이지 (OTC) - /api/products: 제품 검색 API (세트상품 바코드 포함) - qr_printer.py: Brother QL-710W 프린터 연동 - /api/qr-print, /api/qr-preview: QR 라벨 인쇄/미리보기 API - 판매상세 페이지에 QR 인쇄 버튼 추가 - 수량 선택 UI (+/- 버튼, 최대 10장) - 세트상품 제조사 표시 개선 - 대시보드 헤더에 제품검색/판매조회 탭 추가
555 lines
20 KiB
HTML
555 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>알림톡 발송 로그 - 청춘약국</title>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet">
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
background: #f5f7fa;
|
|
-webkit-font-smoothing: antialiased;
|
|
}
|
|
|
|
.header {
|
|
background: linear-gradient(135deg, #0ea5e9 0%, #6366f1 100%);
|
|
padding: 28px 24px;
|
|
color: #fff;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.header-content {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.header-title { font-size: 26px; font-weight: 700; letter-spacing: -0.5px; }
|
|
.header-subtitle { font-size: 14px; opacity: 0.85; margin-top: 4px; }
|
|
|
|
.header-nav a {
|
|
color: rgba(255,255,255,0.85);
|
|
text-decoration: none;
|
|
font-size: 14px;
|
|
padding: 8px 16px;
|
|
border-radius: 8px;
|
|
transition: all 0.2s;
|
|
}
|
|
.header-nav a:hover {
|
|
background: rgba(255,255,255,0.15);
|
|
color: #fff;
|
|
}
|
|
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 24px;
|
|
}
|
|
|
|
/* Stats Cards */
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
gap: 16px;
|
|
margin-bottom: 24px;
|
|
}
|
|
|
|
.stat-card {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
padding: 20px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
}
|
|
|
|
.stat-label { font-size: 13px; color: #64748b; margin-bottom: 8px; }
|
|
.stat-value { font-size: 28px; font-weight: 700; color: #1e293b; }
|
|
.stat-value.success { color: #10b981; }
|
|
.stat-value.fail { color: #ef4444; }
|
|
.stat-value.today { color: #6366f1; }
|
|
|
|
/* Tabs */
|
|
.tabs {
|
|
display: flex;
|
|
gap: 4px;
|
|
margin-bottom: 20px;
|
|
background: #fff;
|
|
padding: 4px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
}
|
|
|
|
.tab {
|
|
padding: 10px 24px;
|
|
border: none;
|
|
background: none;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
color: #64748b;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.tab.active {
|
|
background: #6366f1;
|
|
color: #fff;
|
|
}
|
|
|
|
.tab:hover:not(.active) { background: #f1f5f9; }
|
|
|
|
/* Tab Panels */
|
|
.tab-panel { display: none; }
|
|
.tab-panel.active { display: block; }
|
|
|
|
/* Table */
|
|
.card {
|
|
background: #fff;
|
|
border-radius: 12px;
|
|
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
|
|
overflow: hidden;
|
|
}
|
|
|
|
.card-header {
|
|
padding: 16px 20px;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
.card-title { font-size: 16px; font-weight: 600; color: #1e293b; }
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th {
|
|
padding: 12px 16px;
|
|
text-align: left;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #64748b;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
background: #f8fafc;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
td {
|
|
padding: 12px 16px;
|
|
font-size: 13px;
|
|
color: #334155;
|
|
border-bottom: 1px solid #f1f5f9;
|
|
vertical-align: middle;
|
|
}
|
|
|
|
tr:hover td { background: #f8fafc; }
|
|
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 3px 10px;
|
|
border-radius: 20px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.badge-success { background: #dcfce7; color: #16a34a; }
|
|
.badge-fail { background: #fee2e2; color: #dc2626; }
|
|
.badge-kiosk { background: #dbeafe; color: #2563eb; }
|
|
.badge-admin { background: #f3e8ff; color: #7c3aed; }
|
|
.badge-manual { background: #fef3c7; color: #d97706; }
|
|
.badge-completed { background: #dcfce7; color: #16a34a; }
|
|
.badge-sending { background: #fef3c7; color: #d97706; }
|
|
.badge-failed { background: #fee2e2; color: #dc2626; }
|
|
|
|
.phone-mask { font-family: 'Courier New', monospace; font-size: 13px; }
|
|
|
|
.param-toggle {
|
|
font-size: 12px;
|
|
color: #6366f1;
|
|
cursor: pointer;
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.param-detail {
|
|
display: none;
|
|
margin-top: 8px;
|
|
padding: 8px 12px;
|
|
background: #f8fafc;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
color: #475569;
|
|
white-space: pre-wrap;
|
|
font-family: 'Courier New', monospace;
|
|
}
|
|
|
|
.param-detail.show { display: block; }
|
|
|
|
/* NHN Tab */
|
|
.date-picker-row {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
margin-bottom: 16px;
|
|
}
|
|
|
|
.date-picker-row input {
|
|
padding: 8px 14px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.btn {
|
|
padding: 8px 20px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.btn-primary { background: #6366f1; color: #fff; }
|
|
.btn-primary:hover { background: #4f46e5; }
|
|
.btn-teal { background: #0d9488; color: #fff; }
|
|
.btn-teal:hover { background: #0f766e; }
|
|
.btn-sm { padding: 6px 14px; font-size: 13px; }
|
|
|
|
.loading {
|
|
text-align: center;
|
|
padding: 40px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #94a3b8;
|
|
}
|
|
|
|
.empty-state .icon { font-size: 48px; margin-bottom: 12px; }
|
|
.empty-state .text { font-size: 15px; }
|
|
|
|
/* Test Send */
|
|
.test-form {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: flex-end;
|
|
padding: 16px 20px;
|
|
background: #f8fafc;
|
|
border-bottom: 1px solid #e2e8f0;
|
|
}
|
|
|
|
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
|
.form-group label { font-size: 12px; font-weight: 500; color: #64748b; }
|
|
|
|
.form-group input {
|
|
padding: 8px 12px;
|
|
border: 1px solid #e2e8f0;
|
|
border-radius: 6px;
|
|
font-size: 14px;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 24px;
|
|
right: 24px;
|
|
padding: 14px 20px;
|
|
border-radius: 10px;
|
|
color: #fff;
|
|
font-size: 14px;
|
|
font-weight: 500;
|
|
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
|
|
z-index: 1000;
|
|
transform: translateY(100px);
|
|
opacity: 0;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.toast.show { transform: translateY(0); opacity: 1; }
|
|
.toast.success { background: #10b981; }
|
|
.toast.error { background: #ef4444; }
|
|
|
|
@media (max-width: 768px) {
|
|
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
.test-form { flex-wrap: wrap; }
|
|
.header-nav { display: none; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<div class="header-content">
|
|
<div>
|
|
<div class="header-title">알림톡 발송 로그</div>
|
|
<div class="header-subtitle">NHN Cloud 카카오 알림톡 발송 기록 및 상태 모니터링</div>
|
|
</div>
|
|
<div class="header-nav">
|
|
<a href="/admin">관리자 홈</a>
|
|
<a href="/admin/ai-crm">AI 업셀링</a>
|
|
<a href="/admin/ai-gw">Gateway 모니터</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="container">
|
|
<!-- Stats -->
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="stat-label">전체 발송</div>
|
|
<div class="stat-value">{{ stats.total or 0 }}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">성공</div>
|
|
<div class="stat-value success">{{ stats.success_count or 0 }}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">실패</div>
|
|
<div class="stat-value fail">{{ stats.fail_count or 0 }}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">오늘 발송</div>
|
|
<div class="stat-value today">{{ stats.today_total or 0 }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tabs -->
|
|
<div class="tabs">
|
|
<button class="tab active" onclick="switchTab('local')">발송 로그 (서버)</button>
|
|
<button class="tab" onclick="switchTab('nhn')">NHN Cloud 내역</button>
|
|
<button class="tab" onclick="switchTab('test')">수동 발송</button>
|
|
</div>
|
|
|
|
<!-- Tab 1: Local Logs -->
|
|
<div id="panel-local" class="tab-panel active">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title">서버 발송 로그 (최근 50건)</div>
|
|
</div>
|
|
{% if local_logs %}
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>시간</th>
|
|
<th>수신번호</th>
|
|
<th>고객</th>
|
|
<th>템플릿</th>
|
|
<th>발송 주체</th>
|
|
<th>결과</th>
|
|
<th>상세</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{% for log in local_logs %}
|
|
<tr>
|
|
<td>{{ log.created_at[:16] if log.created_at else '-' }}</td>
|
|
<td class="phone-mask">{{ log.recipient_no[:3] + '-' + log.recipient_no[3:7] + '-' + log.recipient_no[7:] if log.recipient_no|length >= 11 else log.recipient_no }}</td>
|
|
<td>{{ log.nickname or '-' }}</td>
|
|
<td><code>{{ log.template_code }}</code></td>
|
|
<td>
|
|
{% if log.trigger_source == 'kiosk' %}
|
|
<span class="badge badge-kiosk">키오스크</span>
|
|
{% elif log.trigger_source == 'admin_test' %}
|
|
<span class="badge badge-admin">관리자</span>
|
|
{% else %}
|
|
<span class="badge badge-manual">{{ log.trigger_source }}</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if log.success %}
|
|
<span class="badge badge-success">성공</span>
|
|
{% else %}
|
|
<span class="badge badge-fail">실패</span>
|
|
{% endif %}
|
|
</td>
|
|
<td>
|
|
{% if log.template_params %}
|
|
<span class="param-toggle" onclick="toggleParam(this)">변수 보기</span>
|
|
<div class="param-detail">{{ log.template_params }}</div>
|
|
{% endif %}
|
|
{% if not log.success and log.result_message %}
|
|
<div style="color: #ef4444; font-size: 12px; margin-top: 4px;">{{ log.result_message }}</div>
|
|
{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</tbody>
|
|
</table>
|
|
{% else %}
|
|
<div class="empty-state">
|
|
<div class="icon">📭</div>
|
|
<div class="text">아직 발송 기록이 없습니다</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 2: NHN Cloud -->
|
|
<div id="panel-nhn" class="tab-panel">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title">NHN Cloud 발송 내역</div>
|
|
</div>
|
|
<div style="padding: 16px 20px;">
|
|
<div class="date-picker-row">
|
|
<input type="date" id="nhn-date" value="{{ now_date }}" />
|
|
<button class="btn btn-primary" onclick="loadNhnHistory()">조회</button>
|
|
</div>
|
|
</div>
|
|
<div id="nhn-table-area">
|
|
<div class="empty-state">
|
|
<div class="icon">🔍</div>
|
|
<div class="text">날짜를 선택하고 조회를 눌러주세요</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Tab 3: Test Send -->
|
|
<div id="panel-test" class="tab-panel">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="card-title">수동 알림톡 발송 테스트</div>
|
|
</div>
|
|
<div class="test-form">
|
|
<div class="form-group">
|
|
<label>전화번호</label>
|
|
<input type="tel" id="test-phone" placeholder="01012345678" style="width: 160px;" />
|
|
</div>
|
|
<div class="form-group">
|
|
<label>고객명</label>
|
|
<input type="text" id="test-name" placeholder="테스트" value="테스트" style="width: 120px;" />
|
|
</div>
|
|
<button class="btn btn-teal" onclick="sendTest()">테스트 발송</button>
|
|
</div>
|
|
<div style="padding: 20px; color: #64748b; font-size: 13px; line-height: 1.8;">
|
|
<strong>안내</strong><br>
|
|
- MILEAGE_CLAIM_V3 템플릿으로 테스트 메시지를 발송합니다.<br>
|
|
- 테스트 값: 적립 100P, 잔액 500P, 품목 "테스트 발송"<br>
|
|
- 발송 결과는 "발송 로그 (서버)" 탭에서 확인 가능합니다.
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="toast" id="toast"></div>
|
|
|
|
<script>
|
|
// Tab switching
|
|
function switchTab(tabName) {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
|
|
|
event.target.classList.add('active');
|
|
document.getElementById('panel-' + tabName).classList.add('active');
|
|
|
|
if (tabName === 'nhn' && !document.getElementById('nhn-table-area').dataset.loaded) {
|
|
loadNhnHistory();
|
|
}
|
|
}
|
|
|
|
// Toggle param detail
|
|
function toggleParam(el) {
|
|
const detail = el.nextElementSibling;
|
|
detail.classList.toggle('show');
|
|
el.textContent = detail.classList.contains('show') ? '접기' : '변수 보기';
|
|
}
|
|
|
|
// Toast notification
|
|
function showToast(msg, type) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = msg;
|
|
toast.className = 'toast ' + type + ' show';
|
|
setTimeout(() => toast.classList.remove('show'), 3000);
|
|
}
|
|
|
|
// Load NHN history
|
|
async function loadNhnHistory() {
|
|
const date = document.getElementById('nhn-date').value;
|
|
const area = document.getElementById('nhn-table-area');
|
|
area.innerHTML = '<div class="loading">조회 중...</div>';
|
|
|
|
try {
|
|
const resp = await fetch('/api/admin/alimtalk/nhn-history?date=' + date);
|
|
const data = await resp.json();
|
|
area.dataset.loaded = '1';
|
|
|
|
if (!data.messages || data.messages.length === 0) {
|
|
area.innerHTML = '<div class="empty-state"><div class="icon">📭</div><div class="text">' + date + ' 발송 내역이 없습니다</div></div>';
|
|
return;
|
|
}
|
|
|
|
let html = '<table><thead><tr><th>요청 시간</th><th>수신번호</th><th>템플릿</th><th>상태</th><th>결과코드</th></tr></thead><tbody>';
|
|
data.messages.forEach(m => {
|
|
const time = m.requestDate ? m.requestDate.substring(0, 19) : '-';
|
|
const phone = m.recipientNo || '-';
|
|
const tpl = m.templateCode || '-';
|
|
|
|
let statusBadge = '';
|
|
const st = (m.messageStatus || '').toUpperCase();
|
|
if (st === 'COMPLETED') {
|
|
statusBadge = '<span class="badge badge-completed">전송완료</span>';
|
|
} else if (st === 'SENDING' || st === 'READY') {
|
|
statusBadge = '<span class="badge badge-sending">발송중</span>';
|
|
} else {
|
|
statusBadge = '<span class="badge badge-failed">' + (m.messageStatus || '알수없음') + '</span>';
|
|
}
|
|
|
|
const code = m.resultCode || '-';
|
|
|
|
html += '<tr><td>' + time + '</td><td class="phone-mask">' + phone + '</td><td><code>' + tpl + '</code></td><td>' + statusBadge + '</td><td>' + code + '</td></tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
area.innerHTML = html;
|
|
} catch(e) {
|
|
area.innerHTML = '<div class="empty-state"><div class="icon">⚠️</div><div class="text">조회 실패: ' + e.message + '</div></div>';
|
|
}
|
|
}
|
|
|
|
// Test send
|
|
async function sendTest() {
|
|
const phone = document.getElementById('test-phone').value.trim();
|
|
const name = document.getElementById('test-name').value.trim() || '테스트';
|
|
|
|
if (phone.length < 10) {
|
|
showToast('전화번호를 입력해주세요', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const resp = await fetch('/api/admin/alimtalk/test-send', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ phone, name })
|
|
});
|
|
const data = await resp.json();
|
|
|
|
if (data.success) {
|
|
showToast('발송 성공!', 'success');
|
|
} else {
|
|
showToast('발송 실패: ' + data.message, 'error');
|
|
}
|
|
} catch(e) {
|
|
showToast('오류: ' + e.message, 'error');
|
|
}
|
|
}
|
|
|
|
// Set today's date
|
|
document.getElementById('nhn-date').value = new Date().toISOString().split('T')[0];
|
|
</script>
|
|
</body>
|
|
</html>
|