pharmacy-pos-qr-system/backend/templates/admin.html
시골약사 3889e2354f feat: Flask 웹 서버 및 마일리지 적립 기능 구현
- 간편 적립: 전화번호 + 이름만으로 QR 적립
- 자동 회원 가입: 신규 사용자 자동 등록
- 마이페이지: 포인트 조회 및 적립 내역 확인
- 관리자 페이지: 전체 사용자/적립 현황 대시보드
- 거래 세부 조회 API: MSSQL 연동으로 판매 상품 상세 확인
- 모던 UI: Noto Sans KR 폰트, 반응형 디자인
- 포트: 7001 (리버스 프록시: https://mile.0bin.in)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 16:36:14 +09:00

471 lines
19 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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, 'Segoe UI', sans-serif;
background: #f5f7fa;
-webkit-font-smoothing: antialiased;
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
padding: 32px 24px;
color: #ffffff;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.header-content {
max-width: 1400px;
margin: 0 auto;
}
.header-title {
font-size: 28px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: -0.5px;
}
.header-subtitle {
font-size: 15px;
opacity: 0.9;
font-weight: 500;
letter-spacing: -0.2px;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 24px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: #ffffff;
border-radius: 16px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.stat-label {
color: #868e96;
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
letter-spacing: -0.2px;
}
.stat-value {
color: #212529;
font-size: 36px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.primary {
color: #6366f1;
}
.section {
background: #ffffff;
border-radius: 16px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.section-title {
font-size: 20px;
font-weight: 700;
color: #212529;
margin-bottom: 20px;
letter-spacing: -0.3px;
}
.table-responsive {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
text-align: left;
padding: 12px 16px;
background: #f8f9fa;
color: #495057;
font-size: 13px;
font-weight: 600;
border-bottom: 2px solid #e9ecef;
letter-spacing: -0.2px;
}
td {
padding: 14px 16px;
border-bottom: 1px solid #f1f3f5;
color: #495057;
font-size: 14px;
font-weight: 500;
letter-spacing: -0.2px;
}
tr:hover {
background: #f8f9fa;
}
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 100px;
font-size: 12px;
font-weight: 600;
letter-spacing: -0.2px;
}
.badge-success {
background: #d3f9d8;
color: #2b8a3e;
}
.badge-warning {
background: #fff3bf;
color: #e67700;
}
.points-positive {
color: #6366f1;
font-weight: 700;
}
.phone-masked {
font-family: 'Courier New', monospace;
color: #495057;
}
@media (max-width: 768px) {
.stats-grid {
grid-template-columns: 1fr;
}
.table-responsive {
font-size: 12px;
}
th, td {
padding: 10px 12px;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div class="header-title">📊 관리자 대시보드</div>
<div class="header-subtitle">청춘약국 마일리지 관리</div>
</div>
</div>
<div class="container">
<!-- 전체 통계 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">총 가입자 수</div>
<div class="stat-value primary">{{ stats.total_users or 0 }}명</div>
</div>
<div class="stat-card">
<div class="stat-label">누적 포인트 잔액</div>
<div class="stat-value primary">{{ "{:,}".format(stats.total_balance or 0) }}P</div>
</div>
<div class="stat-card">
<div class="stat-label">QR 발행 건수</div>
<div class="stat-value">{{ token_stats.total_tokens or 0 }}건</div>
</div>
<div class="stat-card">
<div class="stat-label">적립 완료율</div>
<div class="stat-value">
{% if token_stats.total_tokens and token_stats.total_tokens > 0 %}
{{ "%.1f"|format((token_stats.claimed_count or 0) * 100.0 / token_stats.total_tokens) }}%
{% else %}
0%
{% endif %}
</div>
</div>
</div>
<!-- 최근 가입 사용자 -->
<div class="section">
<div class="section-title">최근 가입 사용자 (20명)</div>
<div class="table-responsive">
{% if recent_users %}
<table>
<thead>
<tr>
<th>ID</th>
<th>이름</th>
<th>전화번호</th>
<th>포인트</th>
<th>가입일</th>
</tr>
</thead>
<tbody>
{% for user in recent_users %}
<tr>
<td>{{ user.id }}</td>
<td>{{ user.nickname }}</td>
<td class="phone-masked">{{ user.phone[:3] }}-{{ user.phone[3:7] }}-{{ user.phone[7:] if user.phone|length > 7 else '' }}</td>
<td class="points-positive">{{ "{:,}".format(user.mileage_balance) }}P</td>
<td>{{ user.created_at[:16].replace('T', ' ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="text-align: center; padding: 40px; color: #868e96;">가입한 사용자가 없습니다.</p>
{% endif %}
</div>
</div>
<!-- 최근 적립 내역 -->
<div class="section">
<div class="section-title">최근 적립 내역 (50건)</div>
<div class="table-responsive">
{% if recent_transactions %}
<table>
<thead>
<tr>
<th>이름</th>
<th>전화번호</th>
<th>포인트</th>
<th>잔액</th>
<th>내역</th>
<th>일시</th>
</tr>
</thead>
<tbody>
{% for tx in recent_transactions %}
<tr>
<td>{{ tx.nickname }}</td>
<td class="phone-masked">{{ tx.phone[:3] }}-{{ tx.phone[3:7] }}-{{ tx.phone[7:] if tx.phone|length > 7 else '' }}</td>
<td class="points-positive">{{ "{:,}".format(tx.points) }}P</td>
<td>{{ "{:,}".format(tx.balance_after) }}P</td>
<td>{{ tx.description or tx.reason }}</td>
<td>{{ tx.created_at[:16].replace('T', ' ') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="text-align: center; padding: 40px; color: #868e96;">적립 내역이 없습니다.</p>
{% endif %}
</div>
</div>
<!-- 최근 QR 발행 내역 -->
<div class="section">
<div class="section-title">최근 QR 발행 내역 (20건)</div>
<div class="table-responsive">
{% if recent_tokens %}
<table>
<thead>
<tr>
<th>거래번호</th>
<th>판매금액</th>
<th>적립포인트</th>
<th>상태</th>
<th>발행일</th>
<th>적립일</th>
</tr>
</thead>
<tbody>
{% for token in recent_tokens %}
<tr>
<td>
<a href="javascript:void(0)" onclick="showTransactionDetail('{{ token.transaction_id }}')" style="color: #6366f1; text-decoration: none; font-weight: 600; cursor: pointer;">
{{ token.transaction_id }}
</a>
</td>
<td>{{ "{:,}".format(token.total_amount) }}원</td>
<td class="points-positive">{{ "{:,}".format(token.claimable_points) }}P</td>
<td>
{% if token.claimed_at %}
<span class="badge badge-success">적립완료</span>
{% else %}
<span class="badge badge-warning">대기중</span>
{% endif %}
</td>
<td>{{ token.created_at[:16].replace('T', ' ') }}</td>
<td>{{ token.claimed_at[:16].replace('T', ' ') if token.claimed_at else '-' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p style="text-align: center; padding: 40px; color: #868e96;">발행된 QR이 없습니다.</p>
{% endif %}
</div>
</div>
</div>
<!-- 거래 세부 내역 모달 -->
<div id="transactionModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 9999; padding: 20px; overflow-y: auto;">
<div style="max-width: 800px; margin: 40px auto; background: #fff; border-radius: 20px; padding: 32px; position: relative;">
<button onclick="closeModal()" style="position: absolute; top: 20px; right: 20px; background: #f1f3f5; border: none; width: 36px; height: 36px; border-radius: 50%; cursor: pointer; font-size: 20px; color: #495057;">×</button>
<h2 style="font-size: 24px; font-weight: 700; color: #212529; margin-bottom: 24px; letter-spacing: -0.5px;">판매 내역 상세</h2>
<div id="transactionContent" style="min-height: 200px;">
<div style="text-align: center; padding: 60px; color: #868e96;">
<div style="font-size: 14px;">불러오는 중...</div>
</div>
</div>
</div>
</div>
<script>
function showTransactionDetail(transactionId) {
document.getElementById('transactionModal').style.display = 'block';
document.getElementById('transactionContent').innerHTML = '<div style="text-align: center; padding: 60px; color: #868e96;"><div style="font-size: 14px;">불러오는 중...</div></div>';
fetch(`/admin/transaction/${transactionId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
renderTransactionDetail(data);
} else {
document.getElementById('transactionContent').innerHTML = `
<div style="text-align: center; padding: 60px; color: #f03e3e;">
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">오류</div>
<div style="font-size: 14px;">${data.message}</div>
</div>
`;
}
})
.catch(error => {
document.getElementById('transactionContent').innerHTML = `
<div style="text-align: center; padding: 60px; color: #f03e3e;">
<div style="font-size: 16px; font-weight: 600; margin-bottom: 8px;">네트워크 오류</div>
<div style="font-size: 14px;">데이터를 불러올 수 없습니다.</div>
</div>
`;
});
}
function renderTransactionDetail(data) {
const tx = data.transaction;
const items = data.items;
let html = `
<div style="background: #f8f9fa; border-radius: 12px; padding: 20px; margin-bottom: 24px;">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px;">
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">거래번호</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.id}</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">거래일시</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.date}</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">고객명</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.customer_name}</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">총 금액</div>
<div style="color: #495057; font-size: 16px; font-weight: 600;">${tx.total_amount.toLocaleString()}원</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">할인</div>
<div style="color: #f03e3e; font-size: 16px; font-weight: 600;">-${tx.discount.toLocaleString()}원</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">판매 금액</div>
<div style="color: #6366f1; font-size: 18px; font-weight: 700;">${tx.sale_amount.toLocaleString()}원</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">외상</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${tx.credit.toLocaleString()}원</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">수금</div>
<div style="color: #37b24d; font-size: 16px; font-weight: 600;">${tx.received.toLocaleString()}원</div>
</div>
</div>
</div>
<div style="margin-bottom: 16px; font-size: 18px; font-weight: 700; color: #212529;">판매 상품 (${items.length}개)</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #f8f9fa; border-bottom: 2px solid #e9ecef;">
<th style="padding: 12px; text-align: left; font-size: 13px; color: #495057; font-weight: 600;">상품코드</th>
<th style="padding: 12px; text-align: left; font-size: 13px; color: #495057; font-weight: 600;">상품명</th>
<th style="padding: 12px; text-align: right; font-size: 13px; color: #495057; font-weight: 600;">수량</th>
<th style="padding: 12px; text-align: right; font-size: 13px; color: #495057; font-weight: 600;">단가</th>
<th style="padding: 12px; text-align: right; font-size: 13px; color: #495057; font-weight: 600;">합계</th>
</tr>
</thead>
<tbody>
`;
items.forEach(item => {
html += `
<tr style="border-bottom: 1px solid #f1f3f5;">
<td style="padding: 14px; font-size: 14px; color: #495057;">${item.code}</td>
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${item.name}</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #495057;">${item.qty}</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #495057;">${item.price.toLocaleString()}원</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${item.total.toLocaleString()}원</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
document.getElementById('transactionContent').innerHTML = html;
}
function closeModal() {
document.getElementById('transactionModal').style.display = 'none';
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeModal();
}
});
// 모달 배경 클릭 시 닫기
document.getElementById('transactionModal').addEventListener('click', function(e) {
if (e.target === this) {
closeModal();
}
});
</script>
</body>
</html>