pharmacy-pos-qr-system/backend/templates/admin.html
시골약사 59a33cc249 feat: 포인트 사용 기능 및 시간 표시 개선
- UTC to KST 시간 변환 로직 추가 (SQLite 저장 시간 표시용)
- 관리자 페이지에 포인트 사용(차감) 기능 추가
  - 사용자 상세 모달에 "포인트 사용" 버튼 추가
  - 포인트 입력 및 차감 처리
  - 마일리지 원장에 USE 타입으로 기록
- 구매 이력 시간을 MSSQL의 실제 거래 시간(InsertTime)으로 수정
- 선택적 시간 변환 적용
  - 변환: users.created_at, mileage_ledger.created_at, claim_tokens.created_at
  - 미변환: claim_tokens.claimed_at, MSSQL 거래 시간
- 관리자 페이지에 검색 기능 추가 (사이드바)
  - 사용자 검색 (이름, 전화번호, 뒷자리)
  - 제품 검색 (약품명으로 구매자 조회)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-23 21:54:12 +09:00

1242 lines
55 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;
}
}
/* 아코디언 스타일 */
.accordion-content {
transition: max-height 0.3s ease-out;
}
/* 탭 스타일 */
.tab-btn {
transition: all 0.2s ease;
}
.tab-btn:hover {
color: #6366f1 !important;
}
/* 사용자 테이블 행 호버 */
.section table tbody tr {
transition: all 0.2s ease;
}
.section table tbody tr:hover {
background: #f8f9fa;
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
/* 사이드바 레이아웃 */
.layout-wrapper {
display: flex;
max-width: 1600px;
margin: 0 auto;
}
.sidebar {
width: 280px;
background: #ffffff;
min-height: calc(100vh - 112px);
padding: 24px 16px;
box-shadow: 2px 0 8px rgba(0,0,0,0.04);
}
.sidebar-title {
font-size: 18px;
font-weight: 700;
color: #212529;
margin-bottom: 20px;
padding: 0 8px;
}
.search-container {
margin-bottom: 24px;
}
.search-type-toggle {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.search-type-btn {
flex: 1;
padding: 10px;
border: 2px solid #e9ecef;
background: #fff;
border-radius: 10px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: #868e96;
transition: all 0.2s;
}
.search-type-btn.active {
border-color: #6366f1;
background: #f8f9ff;
color: #6366f1;
}
.search-input-wrapper {
position: relative;
}
.search-input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 12px;
font-size: 14px;
font-family: 'Noto Sans KR', sans-serif;
transition: all 0.2s;
}
.search-input:focus {
outline: none;
border-color: #6366f1;
box-shadow: 0 0 0 4px rgba(99,102,241,0.08);
}
.search-btn {
width: 100%;
padding: 12px;
margin-top: 8px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%);
color: #fff;
border: none;
border-radius: 12px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.search-btn:hover {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(99,102,241,0.3);
}
.search-btn:active {
transform: translateY(0);
}
/* 검색 결과 모달 */
.search-results-modal {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 10000;
padding: 20px;
overflow-y: auto;
}
.search-results-content {
max-width: 900px;
margin: 40px auto;
background: #fff;
border-radius: 20px;
padding: 32px;
position: relative;
}
.container {
flex: 1;
padding: 24px;
}
@media (max-width: 768px) {
.sidebar {
display: none;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-content">
<div class="header-title">📊 관리자 대시보드</div>
<div class="header-subtitle">청춘약국 마일리지 관리</div>
</div>
</div>
<div class="layout-wrapper">
<!-- 사이드바 -->
<aside class="sidebar">
<div class="sidebar-title">🔍 검색</div>
<div class="search-container">
<div class="search-type-toggle">
<button class="search-type-btn active" onclick="switchSearchType('user')" id="btn-search-user">
사용자
</button>
<button class="search-type-btn" onclick="switchSearchType('product')" id="btn-search-product">
제품
</button>
</div>
<div class="search-input-wrapper">
<input type="text" class="search-input" id="searchInput" placeholder="이름 또는 전화번호 입력" onkeypress="if(event.key==='Enter') performSearch()">
</div>
<button class="search-btn" onclick="performSearch()">검색</button>
</div>
</aside>
<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 style="cursor: pointer;" onclick="showUserDetail({{ user.id }})">
<td>{{ user.id }}</td>
<td style="color: #6366f1; font-weight: 600;">{{ 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> <!-- .container -->
</div> <!-- .layout-wrapper -->
<!-- 검색 결과 모달 -->
<div id="searchResultsModal" class="search-results-modal">
<div class="search-results-content">
<button onclick="closeSearchResults()" 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;" id="searchResultTitle">검색 결과</h2>
<div id="searchResultContent" style="min-height: 200px;">
<div style="text-align: center; padding: 60px; color: #868e96;">
<div style="font-size: 14px;">검색 결과가 여기에 표시됩니다.</div>
</div>
</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>
<!-- 사용자 상세 모달 -->
<div id="userDetailModal" 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: 900px; margin: 40px auto; background: #fff; border-radius: 20px; padding: 32px; position: relative;">
<button onclick="closeUserModal()" 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="userDetailContent" style="min-height: 200px;">
<div style="text-align: center; padding: 60px; color: #868e96;">
<div style="font-size: 14px;">불러오는 중...</div>
</div>
</div>
</div>
</div>
<!-- 포인트 사용 모달 -->
<div id="usePointsModal" style="display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 10000; padding: 20px; overflow-y: auto;">
<div style="max-width: 500px; margin: 100px auto; background: #fff; border-radius: 20px; padding: 32px; position: relative;">
<button onclick="closeUsePointsModal()" 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 style="background: #f8f9fa; border-radius: 12px; padding: 16px; margin-bottom: 24px;">
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">현재 포인트 잔액</div>
<div id="current-balance-display" style="color: #6366f1; font-size: 24px; font-weight: 700;">0P</div>
</div>
<div style="margin-bottom: 24px;">
<label style="display: block; color: #495057; font-size: 14px; font-weight: 600; margin-bottom: 8px;">사용할 포인트</label>
<input type="number" id="points-to-use" placeholder="포인트 입력" min="1" style="width: 100%; padding: 14px; border: 2px solid #e9ecef; border-radius: 10px; font-size: 16px; font-family: 'Noto Sans KR', sans-serif; box-sizing: border-box;" onkeypress="if(event.key==='Enter') confirmUsePoints()">
<div id="use-points-error" style="color: #f03e3e; font-size: 13px; margin-top: 8px; display: none;"></div>
</div>
<div style="display: flex; gap: 12px;">
<button onclick="closeUsePointsModal()" style="flex: 1; padding: 14px; background: #f1f3f5; color: #495057; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer;">
취소
</button>
<button onclick="confirmUsePoints()" style="flex: 1; padding: 14px; background: #f03e3e; color: white; border: none; border-radius: 10px; font-size: 15px; font-weight: 600; cursor: pointer;">
사용하기
</button>
</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.supply_value.toLocaleString()}원</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.vat.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();
}
});
// ===== 사용자 상세 모달 함수 =====
function showUserDetail(userId) {
document.getElementById('userDetailModal').style.display = 'block';
document.getElementById('userDetailContent').innerHTML = '<div style="text-align: center; padding: 60px; color: #868e96;"><div style="font-size: 14px;">불러오는 중...</div></div>';
fetch(`/admin/user/${userId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
renderUserDetail(data);
} else {
document.getElementById('userDetailContent').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('userDetailContent').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 closeUserModal() {
document.getElementById('userDetailModal').style.display = 'none';
}
function renderUserDetail(data) {
const user = data.user;
const mileageHistory = data.mileage_history;
const purchases = data.purchases;
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; margin-bottom: 16px;">
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">이름</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.name}</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">전화번호</div>
<div style="color: #212529; font-size: 16px; font-weight: 600; font-family: 'Courier New', monospace;">${user.phone}</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">포인트 잔액</div>
<div id="user-balance-display" style="color: #6366f1; font-size: 18px; font-weight: 700;">${user.balance.toLocaleString()}P</div>
</div>
<div>
<div style="color: #868e96; font-size: 13px; margin-bottom: 6px;">가입일</div>
<div style="color: #212529; font-size: 16px; font-weight: 600;">${user.created_at}</div>
</div>
</div>
<div style="text-align: right;">
<button onclick="showUsePointsModal(${user.id}, ${user.balance})" style="padding: 10px 24px; background: #f03e3e; color: white; border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; transition: all 0.2s;">
💳 포인트 사용
</button>
</div>
</div>
<!-- 탭 메뉴 -->
<div style="display: flex; gap: 16px; margin-bottom: 16px; border-bottom: 2px solid #e9ecef;">
<button onclick="switchTab('purchases')" id="tab-purchases" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid #6366f1; color: #6366f1;">
구매 이력 (${purchases.length})
</button>
<button onclick="switchTab('mileage')" id="tab-mileage" class="tab-btn" style="padding: 12px 20px; border: none; background: none; font-size: 15px; font-weight: 600; cursor: pointer; border-bottom: 3px solid transparent; color: #868e96;">
적립 이력 (${mileageHistory.length})
</button>
</div>
<!-- 구매 이력 탭 -->
<div id="tab-content-purchases" class="tab-content">
`;
// 구매 이력 (아코디언)
if (purchases.length > 0) {
purchases.forEach((purchase, index) => {
const accordionId = `accordion-${index}`;
html += `
<div style="border: 1px solid #e9ecef; border-radius: 12px; margin-bottom: 12px; overflow: hidden;">
<!-- 아코디언 헤더 -->
<div onclick="toggleAccordion('${accordionId}')" style="padding: 16px; background: #fff; cursor: pointer; display: flex; justify-content: space-between; align-items: center;">
<div style="flex: 1;">
<div style="font-size: 15px; font-weight: 600; color: #212529; margin-bottom: 6px;">
${purchase.items_summary}
</div>
<div style="font-size: 13px; color: #868e96;">
${purchase.date} | ${purchase.amount.toLocaleString()}원 구매 | ${purchase.points.toLocaleString()}P 적립
</div>
</div>
<div id="${accordionId}-icon" style="width: 24px; height: 24px; color: #868e96; transition: transform 0.3s;">
</div>
</div>
<!-- 아코디언 내용 -->
<div id="${accordionId}" class="accordion-content" style="max-height: 0; overflow: hidden;">
<div style="padding: 16px; background: #f8f9fa; border-top: 1px solid #e9ecef;">
<div style="font-size: 14px; font-weight: 600; color: #495057; margin-bottom: 12px;">
상품 상세 (${purchase.items_count}개)
</div>
<table style="width: 100%; border-collapse: collapse;">
<thead>
<tr style="background: #fff; border-bottom: 1px solid #e9ecef;">
<th style="padding: 10px; text-align: left; font-size: 12px; color: #868e96; font-weight: 600;">상품코드</th>
<th style="padding: 10px; text-align: left; font-size: 12px; color: #868e96; font-weight: 600;">상품명</th>
<th style="padding: 10px; text-align: right; font-size: 12px; color: #868e96; font-weight: 600;">수량</th>
<th style="padding: 10px; text-align: right; font-size: 12px; color: #868e96; font-weight: 600;">단가</th>
<th style="padding: 10px; text-align: right; font-size: 12px; color: #868e96; font-weight: 600;">합계</th>
</tr>
</thead>
<tbody>
`;
purchase.items.forEach(item => {
html += `
<tr style="border-bottom: 1px solid #f1f3f5;">
<td style="padding: 10px; font-size: 13px; color: #495057;">${item.code}</td>
<td style="padding: 10px; font-size: 13px; color: #212529; font-weight: 500;">${item.name}</td>
<td style="padding: 10px; text-align: right; font-size: 13px; color: #495057;">${item.qty}</td>
<td style="padding: 10px; text-align: right; font-size: 13px; color: #495057;">${item.price.toLocaleString()}원</td>
<td style="padding: 10px; text-align: right; font-size: 13px; color: #6366f1; font-weight: 600;">${item.total.toLocaleString()}원</td>
</tr>
`;
});
html += `
</tbody>
</table>
</div>
</div>
</div>
`;
});
} else {
html += '<p style="text-align: center; padding: 40px; color: #868e96;">구매 이력이 없습니다.</p>';
}
html += `
</div>
<!-- 적립 이력 탭 -->
<div id="tab-content-mileage" class="tab-content" style="display: none;">
`;
// 적립 이력 테이블
if (mileageHistory.length > 0) {
html += `
<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>
</tr>
</thead>
<tbody>
`;
mileageHistory.forEach(ml => {
html += `
<tr style="border-bottom: 1px solid #f1f3f5;">
<td style="padding: 14px; font-size: 14px; color: #495057;">${ml.created_at}</td>
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${ml.description || ml.reason}</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${ml.points >= 0 ? '+' : ''}${ml.points.toLocaleString()}P</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #495057;">${ml.balance_after.toLocaleString()}P</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
} else {
html += '<p style="text-align: center; padding: 40px; color: #868e96;">적립 이력이 없습니다.</p>';
}
html += `
</div>
`;
document.getElementById('userDetailContent').innerHTML = html;
}
function toggleAccordion(accordionId) {
const content = document.getElementById(accordionId);
const icon = document.getElementById(accordionId + '-icon');
if (content.style.maxHeight && content.style.maxHeight !== '0px') {
// 닫기
content.style.maxHeight = '0px';
icon.style.transform = 'rotate(0deg)';
} else {
// 열기
content.style.maxHeight = content.scrollHeight + 'px';
icon.style.transform = 'rotate(180deg)';
}
}
function switchTab(tabName) {
// 모든 탭 버튼 비활성화
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.style.borderBottom = '3px solid transparent';
btn.style.color = '#868e96';
});
// 모든 탭 컨텐츠 숨기기
document.querySelectorAll('.tab-content').forEach(content => {
content.style.display = 'none';
});
// 선택된 탭 활성화
document.getElementById('tab-' + tabName).style.borderBottom = '3px solid #6366f1';
document.getElementById('tab-' + tabName).style.color = '#6366f1';
document.getElementById('tab-content-' + tabName).style.display = 'block';
}
// ===== 포인트 사용 기능 =====
let currentUserId = null;
let currentUserBalance = 0;
function showUsePointsModal(userId, balance) {
currentUserId = userId;
currentUserBalance = balance;
// 현재 잔액 표시
document.getElementById('current-balance-display').innerText = balance.toLocaleString() + 'P';
// 입력 필드 초기화
document.getElementById('points-to-use').value = '';
document.getElementById('use-points-error').style.display = 'none';
// 모달 표시
document.getElementById('usePointsModal').style.display = 'block';
// 입력 필드에 포커스
setTimeout(() => {
document.getElementById('points-to-use').focus();
}, 100);
}
function closeUsePointsModal() {
document.getElementById('usePointsModal').style.display = 'none';
currentUserId = null;
currentUserBalance = 0;
}
function confirmUsePoints() {
const pointsInput = document.getElementById('points-to-use');
const points = parseInt(pointsInput.value);
const errorDiv = document.getElementById('use-points-error');
// 유효성 검사
if (!points || points <= 0) {
errorDiv.innerText = '1 이상의 포인트를 입력하세요.';
errorDiv.style.display = 'block';
return;
}
if (points > currentUserBalance) {
errorDiv.innerText = `잔액(${currentUserBalance.toLocaleString()}P)보다 많이 사용할 수 없습니다.`;
errorDiv.style.display = 'block';
return;
}
// API 호출
fetch('/admin/use-points', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
user_id: currentUserId,
points: points
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
// 성공 - userId 저장 후 모달 닫고 사용자 상세 정보 새로고침
const userId = currentUserId; // closeUsePointsModal() 전에 저장
closeUsePointsModal();
showUserDetail(userId);
// 성공 메시지 표시 (선택사항)
alert(`${points.toLocaleString()}P 사용 완료!\n남은 잔액: ${data.new_balance.toLocaleString()}P`);
} else {
// 실패 - 에러 메시지 표시
errorDiv.innerText = data.message || '포인트 사용에 실패했습니다.';
errorDiv.style.display = 'block';
}
})
.catch(error => {
errorDiv.innerText = '네트워크 오류가 발생했습니다.';
errorDiv.style.display = 'block';
});
}
// ESC 키로 모달 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
// 포인트 사용 모달이 열려있으면 먼저 닫기
if (document.getElementById('usePointsModal').style.display === 'block') {
closeUsePointsModal();
} else {
closeUserModal();
}
}
});
// 사용자 모달 배경 클릭 시 닫기
document.getElementById('userDetailModal').addEventListener('click', function(e) {
if (e.target === this) {
closeUserModal();
}
});
// 포인트 사용 모달 배경 클릭 시 닫기
document.getElementById('usePointsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeUsePointsModal();
}
});
// ===== 검색 기능 =====
let currentSearchType = 'user'; // 'user' 또는 'product'
function switchSearchType(type) {
currentSearchType = type;
// 버튼 스타일 변경
document.getElementById('btn-search-user').classList.toggle('active', type === 'user');
document.getElementById('btn-search-product').classList.toggle('active', type === 'product');
// placeholder 변경
const searchInput = document.getElementById('searchInput');
if (type === 'user') {
searchInput.placeholder = '이름 또는 전화번호 입력';
} else {
searchInput.placeholder = '제품명 입력';
}
searchInput.value = '';
}
function performSearch() {
const query = document.getElementById('searchInput').value.trim();
if (!query) {
alert('검색어를 입력하세요.');
return;
}
if (currentSearchType === 'user') {
searchUsers(query);
} else {
searchProducts(query);
}
}
function searchUsers(query) {
// 전화번호 뒷자리인지 확인 (숫자만 있고 4-7자리)
const isPhoneLast = /^\d{4,7}$/.test(query);
const type = isPhoneLast ? 'phone_last' : (query.match(/^\d+$/) ? 'phone' : 'name');
fetch(`/admin/search/user?q=${encodeURIComponent(query)}&type=${type}`)
.then(response => response.json())
.then(data => {
if (data.success) {
if (data.multiple) {
// 여러 명 매칭 → 선택 모달
showUserSelectionModal(data.users, query);
} else if (data.user_id) {
// 단일 매칭 → 바로 사용자 상세 모달
showUserDetail(data.user_id);
} else {
alert('검색 결과가 없습니다.');
}
} else {
alert(data.message || '검색 실패');
}
})
.catch(error => {
alert('검색 중 오류가 발생했습니다.');
console.error(error);
});
}
function searchProducts(query) {
fetch(`/admin/search/product?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(data => {
if (data.success) {
showProductSearchResults(data.results, query);
} else {
alert(data.message || '검색 실패');
}
})
.catch(error => {
alert('검색 중 오류가 발생했습니다.');
console.error(error);
});
}
function showUserSelectionModal(users, query) {
let html = `
<div style="margin-bottom: 16px; color: #868e96; font-size: 14px;">
"${query}" 검색 결과: ${users.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: center; font-size: 13px; color: #495057; font-weight: 600;">선택</th>
</tr>
</thead>
<tbody>
`;
users.forEach(user => {
html += `
<tr style="border-bottom: 1px solid #f1f3f5;">
<td style="padding: 14px; font-size: 14px; color: #212529; font-weight: 500;">${user.name}</td>
<td style="padding: 14px; font-size: 14px; color: #495057; font-family: 'Courier New', monospace;">${user.phone}</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${user.balance.toLocaleString()}P</td>
<td style="padding: 14px; text-align: center;">
<button onclick="selectUser(${user.id})" style="padding: 8px 16px; background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); color: #fff; border: none; border-radius: 8px; font-size: 13px; font-weight: 600; cursor: pointer;">선택</button>
</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
document.getElementById('searchResultTitle').textContent = '사용자 선택';
document.getElementById('searchResultContent').innerHTML = html;
document.getElementById('searchResultsModal').style.display = 'block';
}
function selectUser(userId) {
closeSearchResults();
showUserDetail(userId);
}
function showProductSearchResults(results, query) {
if (results.length === 0) {
document.getElementById('searchResultTitle').textContent = '검색 결과';
document.getElementById('searchResultContent').innerHTML = `
<div style="text-align: center; padding: 60px; color: #868e96;">
<div style="font-size: 16px; margin-bottom: 8px;">검색 결과가 없습니다.</div>
<div style="font-size: 14px;">제품명 "${query}"를 구매하고 적립한 사용자가 없습니다.</div>
</div>
`;
document.getElementById('searchResultsModal').style.display = 'block';
return;
}
let html = `
<div style="margin-bottom: 16px; color: #868e96; font-size: 14px;">
"${query}" 구매 적립자: ${results.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: 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>
</tr>
</thead>
<tbody>
`;
results.forEach(result => {
html += `
<tr style="border-bottom: 1px solid #f1f3f5; cursor: pointer;" onclick="selectUser(${result.user_id})">
<td style="padding: 14px; font-size: 14px; color: #6366f1; font-weight: 600;">${result.user_name}</td>
<td style="padding: 14px; font-size: 14px; color: #495057; font-family: 'Courier New', monospace;">${result.user_phone}</td>
<td style="padding: 14px; font-size: 14px; color: #495057;">${result.purchase_date}</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #495057;">${result.quantity}</td>
<td style="padding: 14px; text-align: right; font-size: 14px; color: #6366f1; font-weight: 600;">${result.total_amount.toLocaleString()}원</td>
</tr>
`;
});
html += `
</tbody>
</table>
`;
document.getElementById('searchResultTitle').textContent = '제품 구매 적립자';
document.getElementById('searchResultContent').innerHTML = html;
document.getElementById('searchResultsModal').style.display = 'block';
}
function closeSearchResults() {
document.getElementById('searchResultsModal').style.display = 'none';
}
// 검색 결과 모달 배경 클릭 시 닫기
document.getElementById('searchResultsModal').addEventListener('click', function(e) {
if (e.target === this) {
closeSearchResults();
}
});
// ESC 키로 검색 결과 모달도 닫기
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeSearchResults();
}
});
</script>
</body>
</html>