diff --git a/backend/app.py b/backend/app.py
index 0cec185..735b57e 100644
--- a/backend/app.py
+++ b/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():
"""관리자 페이지 - 전체 사용자 및 적립 현황"""
diff --git a/backend/templates/admin.html b/backend/templates/admin.html
index 74b26b3..5d7d92f 100644
--- a/backend/templates/admin.html
+++ b/backend/templates/admin.html
@@ -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;
+ }
+ }
@@ -211,7 +340,30 @@
-
+
+
+
+
+
@@ -350,6 +502,22 @@
{% endif %}
+
+
+
+
+
@@ -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 = `
+
+ "${query}" 검색 결과: ${users.length}명
+
+
+
+
+ | 이름 |
+ 전화번호 |
+ 포인트 |
+ 선택 |
+
+
+
+ `;
+
+ users.forEach(user => {
+ html += `
+
+ | ${user.name} |
+ ${user.phone} |
+ ${user.balance.toLocaleString()}P |
+
+
+ |
+
+ `;
+ });
+
+ html += `
+
+
+ `;
+
+ 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 = `
+
+
검색 결과가 없습니다.
+
제품명 "${query}"를 구매하고 적립한 사용자가 없습니다.
+
+ `;
+ document.getElementById('searchResultsModal').style.display = 'block';
+ return;
+ }
+
+ let html = `
+
+ "${query}" 구매 적립자: ${results.length}명
+
+
+
+
+ | 이름 |
+ 전화번호 |
+ 구매일시 |
+ 수량 |
+ 구매금액 |
+
+
+
+ `;
+
+ results.forEach(result => {
+ html += `
+
+ | ${result.user_name} |
+ ${result.user_phone} |
+ ${result.purchase_date} |
+ ${result.quantity} |
+ ${result.total_amount.toLocaleString()}원 |
+
+ `;
+ });
+
+ html += `
+
+
+ `;
+
+ 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();
+ }
+ });