feat: AI CRM 어드민 대시보드 + 바텀시트 드래그 닫기 + UTF-8 인코딩 + 문서화

- /admin/ai-crm: AI 업셀링 추천 생성 현황 대시보드 (통계 카드 + 로그 테이블 + 아코디언 상세)
- 마이페이지 바텀시트: 터치 드래그로 닫기 기능 추가 (80px 임계값)
- Windows 콘솔 UTF-8 인코딩 강제 (app.py, clawdbot_client.py)
- admin.html 헤더에 AI CRM 네비 링크 추가
- docs: ai-upselling-crm.md, windows-utf8-encoding.md 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
thug0bin
2026-02-26 20:38:04 +09:00
parent b5a99f7b3b
commit 5042cffb9f
7 changed files with 840 additions and 15 deletions

View File

@@ -398,7 +398,10 @@
<div class="header-title">📊 관리자 대시보드</div>
<div class="header-subtitle">청춘약국 마일리지 관리</div>
</div>
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡 로그</a>
<div style="display:flex;gap:8px;">
<a href="/admin/ai-crm" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🤖 AI CRM</a>
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡 로그</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,412 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI 업셀링 CRM - 청춘약국</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;600;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: #f8fafc;
-webkit-font-smoothing: antialiased;
color: #1e293b;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #7c3aed 0%, #6366f1 50%, #8b5cf6 100%);
padding: 28px 32px 24px;
color: #fff;
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-nav a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.header-nav a:hover { color: #fff; }
.header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
}
.header p {
font-size: 14px;
opacity: 0.85;
font-weight: 400;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1100px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 통계 카드 ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 14px;
margin-bottom: 28px;
}
.stat-card {
background: #fff;
border-radius: 14px;
padding: 20px;
border: 1px solid #e2e8f0;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
letter-spacing: -0.2px;
margin-bottom: 8px;
text-transform: uppercase;
}
.stat-value {
font-size: 32px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.default { color: #1e293b; }
.stat-value.green { color: #16a34a; }
.stat-value.orange { color: #d97706; }
.stat-value.indigo { color: #6366f1; }
/* ── 테이블 섹션 ── */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.section-title {
font-size: 17px;
font-weight: 700;
color: #1e293b;
letter-spacing: -0.3px;
}
.section-sub {
font-size: 13px;
color: #94a3b8;
font-weight: 500;
}
.table-wrap {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #f8fafc;
padding: 12px 14px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 1px solid #e2e8f0;
letter-spacing: -0.2px;
white-space: nowrap;
}
tbody td {
padding: 14px;
font-size: 13px;
font-weight: 500;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tbody tr { cursor: pointer; transition: background .15s; }
tbody tr:hover { background: #f8fafc; }
tbody tr:last-child td { border-bottom: none; }
/* ── 배지 ── */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 100px;
font-size: 11px;
font-weight: 600;
letter-spacing: -0.2px;
}
.badge-active { background: #dcfce7; color: #16a34a; }
.badge-dismissed { background: #f1f5f9; color: #64748b; }
.badge-expired { background: #fee2e2; color: #dc2626; }
.badge-trigger {
background: #dbeafe;
color: #2563eb;
margin: 1px 2px;
font-size: 11px;
padding: 2px 8px;
}
.badge-product {
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
padding: 4px 12px;
font-size: 12px;
}
/* ── 메시지 말줄임 ── */
.msg-ellipsis {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #64748b;
font-size: 12px;
}
/* ── 노출 횟수 ── */
.display-count {
text-align: center;
font-weight: 700;
color: #6366f1;
font-size: 14px;
}
.display-count.zero { color: #cbd5e1; }
/* ── 아코디언 상세 ── */
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-row td {
padding: 0;
border-bottom: 1px solid #e2e8f0;
}
.detail-content {
padding: 20px 24px;
background: #fafbfd;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.detail-field {
margin-bottom: 2px;
}
.detail-label {
font-size: 11px;
font-weight: 600;
color: #94a3b8;
text-transform: uppercase;
margin-bottom: 4px;
letter-spacing: 0.3px;
}
.detail-value {
font-size: 13px;
font-weight: 500;
color: #334155;
line-height: 1.5;
}
.detail-raw {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #e2e8f0;
}
.detail-raw pre {
background: #1e293b;
color: #e2e8f0;
padding: 14px 16px;
border-radius: 10px;
font-size: 12px;
line-height: 1.6;
overflow-x: auto;
font-family: 'SF Mono', 'Consolas', 'Monaco', monospace;
white-space: pre-wrap;
word-break: break-all;
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
.empty-text { font-size: 14px; font-weight: 500; }
/* ── 반응형 ── */
@media (max-width: 768px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.detail-grid { grid-template-columns: 1fr; }
.header { padding: 20px 16px 18px; }
.content { padding: 16px 12px 40px; }
.table-wrap { overflow-x: auto; }
table { min-width: 700px; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<a href="/admin/alimtalk">알림톡 로그 →</a>
</div>
<h1>AI 업셀링 CRM</h1>
<p>구매 기반 맞춤 추천 생성 현황 · Clawdbot Gateway</p>
</div>
<div class="content">
<!-- 통계 카드 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">전체 생성</div>
<div class="stat-value default">{{ stats.total or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">Active</div>
<div class="stat-value green">{{ stats.active_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">고객 반응 (닫기)</div>
<div class="stat-value orange">{{ stats.dismissed_count or 0 }}</div>
</div>
<div class="stat-card">
<div class="stat-label">오늘 생성</div>
<div class="stat-value indigo">{{ stats.today_count or 0 }}</div>
</div>
</div>
<!-- 추천 목록 -->
<div class="section-header">
<div class="section-title">추천 생성 로그</div>
<div class="section-sub">최근 50건 · 클릭하여 상세 보기</div>
</div>
{% if recs %}
<div class="table-wrap">
<table>
<thead>
<tr>
<th>생성일시</th>
<th>고객</th>
<th>트리거 품목</th>
<th>추천 제품</th>
<th>AI 메시지</th>
<th>상태</th>
<th style="text-align:center">노출</th>
</tr>
</thead>
<tbody>
{% for rec in recs %}
<tr onclick="toggleDetail({{ rec.id }})">
<td style="white-space:nowrap;font-size:12px;color:#64748b;">
{{ rec.created_at[5:16] if rec.created_at else '-' }}
</td>
<td>
<div style="font-weight:600;font-size:13px;">{{ rec.nickname or '알 수 없음' }}</div>
{% if rec.user_phone %}
<div style="font-size:11px;color:#94a3b8;">{{ rec.user_phone[:3] }}-****-{{ rec.user_phone[-4:] }}</div>
{% endif %}
</td>
<td>
{% if rec.trigger_list %}
{% for item in rec.trigger_list %}
<span class="badge badge-trigger">{{ item }}</span>
{% endfor %}
{% else %}
<span style="color:#cbd5e1;">-</span>
{% endif %}
</td>
<td>
<span class="badge badge-product">{{ rec.recommended_product }}</span>
</td>
<td>
<div class="msg-ellipsis" title="{{ rec.recommendation_message }}">{{ rec.recommendation_message }}</div>
</td>
<td>
{% if rec.status == 'active' and (not rec.expires_at or rec.expires_at > now) %}
<span class="badge badge-active">Active</span>
{% elif rec.status == 'dismissed' %}
<span class="badge badge-dismissed">Dismissed</span>
{% else %}
<span class="badge badge-expired">Expired</span>
{% endif %}
</td>
<td class="display-count {{ 'zero' if not rec.displayed_count else '' }}">
{{ rec.displayed_count or 0 }}
</td>
</tr>
<!-- 상세 아코디언 -->
<tr class="detail-row" id="detail-{{ rec.id }}">
<td colspan="7">
<div class="detail-content">
<div class="detail-grid">
<div class="detail-field">
<div class="detail-label">추천 이유</div>
<div class="detail-value">{{ rec.recommendation_reason or '-' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">거래 ID</div>
<div class="detail-value">{{ rec.transaction_id or '-' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">노출 일시</div>
<div class="detail-value">{{ rec.displayed_at or '미노출' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">닫기 일시</div>
<div class="detail-value">{{ rec.dismissed_at or '-' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">만료 일시</div>
<div class="detail-value">{{ rec.expires_at or '없음' }}</div>
</div>
<div class="detail-field">
<div class="detail-label">노출 횟수</div>
<div class="detail-value">{{ rec.displayed_count or 0 }}회</div>
</div>
</div>
{% if rec.ai_raw_response %}
<div class="detail-raw">
<div class="detail-label">AI 원본 응답</div>
<pre>{{ rec.ai_raw_response }}</pre>
</div>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="table-wrap">
<div class="empty-state">
<div class="empty-icon">🤖</div>
<div class="empty-text">아직 생성된 AI 추천이 없습니다</div>
</div>
</div>
{% endif %}
</div>
<script>
function toggleDetail(id) {
const row = document.getElementById('detail-' + id);
if (!row) return;
// 다른 열린 것 닫기
document.querySelectorAll('.detail-row.open').forEach(function(el) {
if (el.id !== 'detail-' + id) el.classList.remove('open');
});
row.classList.toggle('open');
}
</script>
</body>
</html>

View File

@@ -396,16 +396,21 @@
<!-- AI 추천 바텀시트 -->
<div id="rec-sheet" style="display:none;">
<div id="rec-backdrop" style="position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.3);z-index:999;animation:recFadeIn .3s ease;"></div>
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:12px 24px 32px;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);">
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
<div style="text-align:center;padding:8px 0 20px;">
<div style="font-size:48px;margin-bottom:16px;">💊</div>
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
<div id="rec-content" style="position:fixed;bottom:0;left:50%;transform:translateX(-50%);width:100%;max-width:420px;background:#fff;border-radius:24px 24px 0 0;padding:0 0 0;box-shadow:0 -8px 32px rgba(0,0,0,0.12);z-index:1000;animation:recSlideUp .4s cubic-bezier(.16,1,.3,1);touch-action:none;">
<!-- 드래그 핸들 영역 -->
<div id="rec-drag-handle" style="padding:12px 24px 0;cursor:grab;">
<div style="width:40px;height:4px;background:#dee2e6;border-radius:2px;margin:0 auto 20px;"></div>
</div>
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
<button onclick="dismissRec()" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
<button onclick="dismissRec()" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
<div style="padding:0 24px 32px;">
<div style="text-align:center;padding:8px 0 20px;">
<div style="font-size:48px;margin-bottom:16px;">💊</div>
<div id="rec-message" style="color:#343a40;font-size:16px;font-weight:500;line-height:1.6;letter-spacing:-0.3px;margin-bottom:16px;"></div>
<div id="rec-product" style="display:inline-block;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:14px;font-weight:600;padding:8px 20px;border-radius:20px;letter-spacing:-0.2px;"></div>
</div>
<div style="display:flex;gap:12px;padding-bottom:env(safe-area-inset-bottom,0);">
<button onclick="dismissRec()" style="flex:1;padding:14px;border:1px solid #dee2e6;border-radius:14px;background:#fff;color:#868e96;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">다음에요</button>
<button onclick="dismissRec()" style="flex:2;padding:14px;border:none;border-radius:14px;background:linear-gradient(135deg,#6366f1,#8b5cf6);color:#fff;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit;">관심있어요!</button>
</div>
</div>
</div>
</div>
@@ -416,6 +421,87 @@
</style>
<script>
let _recId = null;
// ── 드래그 닫기 ──
(function() {
let startY = 0, currentY = 0, isDragging = false;
const DISMISS_THRESHOLD = 80;
function getContent() { return document.getElementById('rec-content'); }
function getBackdrop() { return document.getElementById('rec-backdrop'); }
function onStart(y) {
const c = getContent();
if (!c) return;
isDragging = true;
startY = y;
currentY = 0;
c.style.animation = 'none';
c.style.transition = 'none';
}
function onMove(y) {
if (!isDragging) return;
const c = getContent();
const b = getBackdrop();
currentY = Math.max(0, y - startY); // 아래로만
c.style.transform = 'translate(-50%, ' + currentY + 'px)';
// 배경 투명도도 같이
const opacity = Math.max(0, 0.3 * (1 - currentY / 300));
b.style.background = 'rgba(0,0,0,' + opacity + ')';
}
function onEnd() {
if (!isDragging) return;
isDragging = false;
const c = getContent();
if (currentY > DISMISS_THRESHOLD) {
// 충분히 내렸으면 닫기
c.style.transition = 'transform .25s ease';
c.style.transform = 'translate(-50%, 100%)';
getBackdrop().style.transition = 'opacity .25s';
getBackdrop().style.opacity = '0';
setTimeout(function() {
document.getElementById('rec-sheet').style.display = 'none';
c.style.transition = '';
c.style.transform = '';
}, 250);
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
} else {
// 복귀
c.style.transition = 'transform .25s cubic-bezier(.16,1,.3,1)';
c.style.transform = 'translate(-50%, 0)';
getBackdrop().style.transition = 'background .25s';
getBackdrop().style.background = 'rgba(0,0,0,0.3)';
setTimeout(function() { c.style.transition = ''; }, 250);
}
}
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('rec-content');
if (!el) return;
// 터치 (모바일)
el.addEventListener('touchstart', function(e) {
onStart(e.touches[0].clientY);
}, {passive: true});
el.addEventListener('touchmove', function(e) {
if (isDragging && currentY > 0) e.preventDefault();
onMove(e.touches[0].clientY);
}, {passive: false});
el.addEventListener('touchend', onEnd);
// 마우스 (데스크톱 테스트용)
el.addEventListener('mousedown', function(e) {
if (e.target.tagName === 'BUTTON') return;
onStart(e.clientY);
});
document.addEventListener('mousemove', function(e) {
if (isDragging) onMove(e.clientY);
});
document.addEventListener('mouseup', onEnd);
});
})();
// ── 추천 로드 ──
window.addEventListener('load', function() {
{% if user_id %}
setTimeout(async function() {
@@ -429,17 +515,25 @@
document.getElementById('rec-sheet').style.display = 'block';
document.getElementById('rec-backdrop').onclick = dismissRec;
}
} catch(e) {}
} catch(e) {
console.error('[AI추천] 에러:', e);
}
}, 1500);
{% endif %}
});
function dismissRec() {
const c = document.getElementById('rec-content');
const b = document.getElementById('rec-backdrop');
c.style.animation = 'recSlideDown .3s ease forwards';
c.style.transition = 'transform .3s ease';
c.style.transform = 'translate(-50%, 100%)';
b.style.opacity = '0';
b.style.transition = 'opacity .3s';
setTimeout(function(){ document.getElementById('rec-sheet').style.display='none'; }, 300);
setTimeout(function(){
document.getElementById('rec-sheet').style.display='none';
c.style.transition = '';
c.style.transform = '';
}, 300);
if (_recId) fetch('/api/recommendation/' + _recId + '/dismiss', {method:'POST'}).catch(function(){});
}
</script>