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:
parent
a652d54ad3
commit
7627efbdfb
158
backend/app.py
158
backend/app.py
@ -595,6 +595,164 @@ def admin_user_detail(user_id):
|
||||
}), 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')
|
||||
def admin():
|
||||
"""관리자 페이지 - 전체 사용자 및 적립 현황"""
|
||||
|
||||
@ -201,6 +201,135 @@
|
||||
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>
|
||||
@ -211,7 +340,30 @@
|
||||
</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="stat-card">
|
||||
@ -350,6 +502,22 @@
|
||||
{% 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>
|
||||
|
||||
<!-- 거래 세부 내역 모달 -->
|
||||
@ -744,6 +912,202 @@
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user