- /admin/products: 전체 제품 검색 페이지 (OTC) - /api/products: 제품 검색 API (세트상품 바코드 포함) - qr_printer.py: Brother QL-710W 프린터 연동 - /api/qr-print, /api/qr-preview: QR 라벨 인쇄/미리보기 API - 판매상세 페이지에 QR 인쇄 버튼 추가 - 수량 선택 UI (+/- 버튼, 최대 10장) - 세트상품 제조사 표시 개선 - 대시보드 헤더에 제품검색/판매조회 탭 추가
419 lines
15 KiB
HTML
419 lines
15 KiB
HTML
<!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-interested { background: #fef3c7; color: #d97706; }
|
|
.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>
|
|
<div>
|
|
<a href="/admin/ai-gw" style="margin-right: 16px;">Gateway 모니터</a>
|
|
<a href="/admin/alimtalk">알림톡 로그 →</a>
|
|
</div>
|
|
</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.interested_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 == 'interested' %}
|
|
<span class="badge badge-interested">관심있어요</span>
|
|
{% elif 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>
|