feat: 관리자 페이지 사이드바 및 검색 기능 추가

- 왼쪽 사이드바 추가 (280px, 검색 UI 포함)
- 사용자 검색: 이름/전화번호/전화번호 뒷자리 검색
- 제품 검색: SQLite 적립자 기준으로 구매자 목록 표시
- 다중 매칭 시 선택 모달 표시
- 검색 결과 클릭 시 사용자 상세 모달 연동
- 모바일 반응형 (768px 이하 사이드바 숨김)

API 엔드포인트:
- GET /admin/search/user?q={검색어}&type={name|phone|phone_last}
- GET /admin/search/product?q={제품명}

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
시골약사 2026-01-23 21:19:35 +09:00
parent a652d54ad3
commit 7627efbdfb
2 changed files with 523 additions and 1 deletions

View File

@ -595,6 +595,164 @@ def admin_user_detail(user_id):
}), 500 }), 500
@app.route('/admin/search/user')
def admin_search_user():
"""사용자 검색 (이름/전화번호/전화번호 뒷자리)"""
query = request.args.get('q', '').strip()
search_type = request.args.get('type', 'name') # 'name', 'phone', 'phone_last'
if not query:
return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
try:
if search_type == 'phone_last':
# 전화번호 뒷자리 검색
cursor.execute("""
SELECT id, nickname, phone, mileage_balance
FROM users
WHERE phone LIKE ?
ORDER BY created_at DESC
""", (f'%{query}',))
elif search_type == 'phone':
# 전체 전화번호 검색
cursor.execute("""
SELECT id, nickname, phone, mileage_balance
FROM users
WHERE phone = ?
""", (query,))
else:
# 이름 검색
cursor.execute("""
SELECT id, nickname, phone, mileage_balance
FROM users
WHERE nickname LIKE ?
ORDER BY created_at DESC
""", (f'%{query}%',))
results = cursor.fetchall()
if not results:
return jsonify({'success': False, 'message': '검색 결과가 없습니다'}), 404
if len(results) == 1:
# 단일 매칭 - user_id만 반환
return jsonify({
'success': True,
'multiple': False,
'user_id': results[0]['id']
})
else:
# 여러 명 매칭 - 선택 모달용 데이터 반환
users = [{
'id': row['id'],
'name': row['nickname'],
'phone': row['phone'],
'balance': row['mileage_balance']
} for row in results]
return jsonify({
'success': True,
'multiple': True,
'users': users
})
except Exception as e:
return jsonify({
'success': False,
'message': f'검색 실패: {str(e)}'
}), 500
@app.route('/admin/search/product')
def admin_search_product():
"""제품 검색 - 적립자 목록 반환 (SQLite 적립자 기준)"""
query = request.args.get('q', '').strip()
if not query:
return jsonify({'success': False, 'message': '검색어를 입력하세요'}), 400
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
try:
# 1. MSSQL에서 제품명으로 거래번호 찾기
session = db_manager.get_session('PM_PRES')
sale_items_query = text("""
SELECT DISTINCT
S.SL_NO_order,
S.SL_NM_item,
M.InsertTime
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
LEFT JOIN SALE_MAIN M ON S.SL_NO_order = M.SL_NO_order
WHERE G.GoodsName LIKE :product_name
ORDER BY M.InsertTime DESC
""")
sale_results = session.execute(sale_items_query, {
'product_name': f'%{query}%'
}).fetchall()
if not sale_results:
return jsonify({
'success': True,
'results': []
})
# 2. SQLite에서 적립된 거래만 필터링 (claimed_by_user_id IS NOT NULL)
transaction_ids = [row.SL_NO_order for row in sale_results]
placeholders = ','.join('?' * len(transaction_ids))
cursor.execute(f"""
SELECT
ct.transaction_id,
ct.total_amount,
ct.claimed_at,
ct.claimed_by_user_id,
u.nickname,
u.phone
FROM claim_tokens ct
JOIN users u ON ct.claimed_by_user_id = u.id
WHERE ct.transaction_id IN ({placeholders})
AND ct.claimed_by_user_id IS NOT NULL
ORDER BY ct.claimed_at DESC
LIMIT 50
""", transaction_ids)
claimed_results = cursor.fetchall()
# 3. 결과 조합
results = []
for claim_row in claimed_results:
# 해당 거래의 MSSQL 정보 찾기
mssql_row = next((r for r in sale_results if r.SL_NO_order == claim_row['transaction_id']), None)
if mssql_row:
results.append({
'user_id': claim_row['claimed_by_user_id'],
'user_name': claim_row['nickname'],
'user_phone': claim_row['phone'],
'purchase_date': claim_row['claimed_at'][:16].replace('T', ' ') if claim_row['claimed_at'] else '-',
'quantity': float(mssql_row.SL_NM_item or 0),
'total_amount': int(claim_row['total_amount'])
})
return jsonify({
'success': True,
'results': results
})
except Exception as e:
return jsonify({
'success': False,
'message': f'검색 실패: {str(e)}'
}), 500
@app.route('/admin') @app.route('/admin')
def admin(): def admin():
"""관리자 페이지 - 전체 사용자 및 적립 현황""" """관리자 페이지 - 전체 사용자 및 적립 현황"""

View File

@ -201,6 +201,135 @@
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.05); 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> </style>
</head> </head>
<body> <body>
@ -211,7 +340,30 @@
</div> </div>
</div> </div>
<div class="container"> <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="stats-grid">
<div class="stat-card"> <div class="stat-card">
@ -350,6 +502,22 @@
{% endif %} {% endif %}
</div> </div>
</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>
<!-- 거래 세부 내역 모달 --> <!-- 거래 세부 내역 모달 -->
@ -744,6 +912,202 @@
closeUserModal(); closeUserModal();
} }
}); });
// ===== 검색 기능 =====
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> </script>
</body> </body>
</html> </html>