feat: 회원 검색 페이지 및 API 추가
- /admin/members: 회원 검색 페이지 (팜IT3000 CD_PERSON) - /api/members/search: 이름/전화번호 검색 API (TEL_NO, PHONE, PHONE2) - /api/members/<cuscode>: 회원 상세 + 메모 조회 API - /api/message/send: 알림톡/SMS 발송 API (테스트 모드) - 대시보드 헤더에 회원검색 탭 추가 - 다중 선택 + 일괄 발송 UI
This commit is contained in:
parent
9bd2174501
commit
705696a7fb
231
backend/app.py
231
backend/app.py
@ -2469,6 +2469,12 @@ def admin_products():
|
||||
return render_template('admin_products.html')
|
||||
|
||||
|
||||
@app.route('/admin/members')
|
||||
def admin_members():
|
||||
"""회원 검색 페이지 (팜IT3000 CD_PERSON, 알림톡/SMS 발송)"""
|
||||
return render_template('admin_members.html')
|
||||
|
||||
|
||||
@app.route('/api/products')
|
||||
def api_products():
|
||||
"""
|
||||
@ -2842,6 +2848,231 @@ def api_claude_status():
|
||||
}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 회원 검색 API (팜IT3000 CD_PERSON)
|
||||
# =============================================================================
|
||||
|
||||
@app.route('/api/members/search')
|
||||
def api_members_search():
|
||||
"""
|
||||
회원 검색 API
|
||||
- 이름 또는 전화번호로 검색
|
||||
- PM_BASE.dbo.CD_PERSON 테이블 조회
|
||||
"""
|
||||
search = request.args.get('q', '').strip()
|
||||
search_type = request.args.get('type', 'auto') # auto, name, phone
|
||||
limit = min(int(request.args.get('limit', 50)), 200)
|
||||
|
||||
if not search or len(search) < 2:
|
||||
return jsonify({'success': False, 'error': '검색어는 2글자 이상 입력하세요'})
|
||||
|
||||
try:
|
||||
# PM_BASE 연결
|
||||
base_session = db_manager.get_session('PM_BASE')
|
||||
|
||||
# 검색 타입 자동 감지
|
||||
if search_type == 'auto':
|
||||
# 숫자만 있으면 전화번호, 아니면 이름
|
||||
if search.replace('-', '').replace(' ', '').isdigit():
|
||||
search_type = 'phone'
|
||||
else:
|
||||
search_type = 'name'
|
||||
|
||||
# 전화번호 정규화
|
||||
phone_search = search.replace('-', '').replace(' ', '')
|
||||
|
||||
if search_type == 'phone':
|
||||
# 전화번호 검색 (3개 컬럼 모두)
|
||||
query = text(f"""
|
||||
SELECT TOP {limit}
|
||||
CUSCODE, PANAME, PANUM,
|
||||
TEL_NO, PHONE, PHONE2,
|
||||
CUSETC, EMAIL, SMS_STOP
|
||||
FROM CD_PERSON
|
||||
WHERE
|
||||
REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') LIKE :phone
|
||||
OR REPLACE(REPLACE(PHONE, '-', ''), ' ', '') LIKE :phone
|
||||
OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') LIKE :phone
|
||||
ORDER BY PANAME
|
||||
""")
|
||||
rows = base_session.execute(query, {'phone': f'%{phone_search}%'}).fetchall()
|
||||
else:
|
||||
# 이름 검색
|
||||
query = text(f"""
|
||||
SELECT TOP {limit}
|
||||
CUSCODE, PANAME, PANUM,
|
||||
TEL_NO, PHONE, PHONE2,
|
||||
CUSETC, EMAIL, SMS_STOP
|
||||
FROM CD_PERSON
|
||||
WHERE PANAME LIKE :name
|
||||
ORDER BY PANAME
|
||||
""")
|
||||
rows = base_session.execute(query, {'name': f'%{search}%'}).fetchall()
|
||||
|
||||
members = []
|
||||
for row in rows:
|
||||
# 유효한 전화번호 찾기 (PHONE 우선, 없으면 TEL_NO, PHONE2)
|
||||
phone = row.PHONE or row.TEL_NO or row.PHONE2 or ''
|
||||
phone = phone.strip() if phone else ''
|
||||
|
||||
members.append({
|
||||
'cuscode': row.CUSCODE,
|
||||
'name': row.PANAME or '',
|
||||
'panum': row.PANUM or '',
|
||||
'phone': phone,
|
||||
'tel_no': row.TEL_NO or '',
|
||||
'phone1': row.PHONE or '',
|
||||
'phone2': row.PHONE2 or '',
|
||||
'memo': (row.CUSETC or '')[:100], # 메모 100자 제한
|
||||
'email': row.EMAIL or '',
|
||||
'sms_stop': row.SMS_STOP == 'Y' # SMS 수신거부 여부
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': members,
|
||||
'count': len(members),
|
||||
'search_type': search_type
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"회원 검색 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@app.route('/api/members/<cuscode>')
|
||||
def api_member_detail(cuscode):
|
||||
"""회원 상세 정보 + 메모 조회"""
|
||||
try:
|
||||
base_session = db_manager.get_session('PM_BASE')
|
||||
|
||||
# 회원 정보
|
||||
query = text("""
|
||||
SELECT
|
||||
CUSCODE, PANAME, PANUM,
|
||||
TEL_NO, PHONE, PHONE2,
|
||||
CUSETC, EMAIL, ADDRESS, SMS_STOP
|
||||
FROM CD_PERSON
|
||||
WHERE CUSCODE = :cuscode
|
||||
""")
|
||||
row = base_session.execute(query, {'cuscode': cuscode}).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({'success': False, 'error': '회원을 찾을 수 없습니다'}), 404
|
||||
|
||||
member = {
|
||||
'cuscode': row.CUSCODE,
|
||||
'name': row.PANAME or '',
|
||||
'panum': row.PANUM or '',
|
||||
'phone': row.PHONE or row.TEL_NO or row.PHONE2 or '',
|
||||
'tel_no': row.TEL_NO or '',
|
||||
'phone1': row.PHONE or '',
|
||||
'phone2': row.PHONE2 or '',
|
||||
'memo': row.CUSETC or '',
|
||||
'email': row.EMAIL or '',
|
||||
'address': row.ADDRESS or '',
|
||||
'sms_stop': row.SMS_STOP == 'Y'
|
||||
}
|
||||
|
||||
# 상세 메모 조회
|
||||
memo_query = text("""
|
||||
SELECT MEMO_CODE, PHARMA_ID, MEMO_DATE, MEMO_TITLE, MEMO_Item
|
||||
FROM CD_PERSON_MEMO
|
||||
WHERE CUSCODE = :cuscode
|
||||
ORDER BY MEMO_DATE DESC
|
||||
""")
|
||||
memo_rows = base_session.execute(memo_query, {'cuscode': cuscode}).fetchall()
|
||||
|
||||
memos = []
|
||||
for m in memo_rows:
|
||||
memos.append({
|
||||
'memo_code': m.MEMO_CODE,
|
||||
'author': m.PHARMA_ID or '',
|
||||
'date': m.MEMO_DATE or '',
|
||||
'title': m.MEMO_TITLE or '',
|
||||
'content': m.MEMO_Item or ''
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'member': member,
|
||||
'memos': memos
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"회원 상세 조회 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 알림톡/SMS 발송 API
|
||||
# =============================================================================
|
||||
|
||||
@app.route('/api/message/send', methods=['POST'])
|
||||
def api_message_send():
|
||||
"""
|
||||
알림톡/SMS 발송 API
|
||||
|
||||
Body:
|
||||
{
|
||||
"recipients": [{"cuscode": "", "name": "", "phone": ""}],
|
||||
"message": "메시지 내용",
|
||||
"type": "alimtalk" | "sms"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': '데이터가 없습니다'}), 400
|
||||
|
||||
recipients = data.get('recipients', [])
|
||||
message = data.get('message', '')
|
||||
msg_type = data.get('type', 'sms') # alimtalk 또는 sms
|
||||
|
||||
if not recipients:
|
||||
return jsonify({'success': False, 'error': '수신자가 없습니다'}), 400
|
||||
if not message:
|
||||
return jsonify({'success': False, 'error': '메시지 내용이 없습니다'}), 400
|
||||
|
||||
# 전화번호 정규화
|
||||
valid_recipients = []
|
||||
for r in recipients:
|
||||
phone = (r.get('phone') or '').replace('-', '').replace(' ', '')
|
||||
if phone and len(phone) >= 10:
|
||||
valid_recipients.append({
|
||||
'cuscode': r.get('cuscode', ''),
|
||||
'name': r.get('name', ''),
|
||||
'phone': phone
|
||||
})
|
||||
|
||||
if not valid_recipients:
|
||||
return jsonify({'success': False, 'error': '유효한 전화번호가 없습니다'}), 400
|
||||
|
||||
# TODO: 실제 발송 로직 (NHN Cloud 알림톡/SMS)
|
||||
# 현재는 테스트 모드로 성공 응답
|
||||
|
||||
results = []
|
||||
for r in valid_recipients:
|
||||
results.append({
|
||||
'phone': r['phone'],
|
||||
'name': r['name'],
|
||||
'status': 'success', # 실제 발송 시 결과로 변경
|
||||
'message': f'{msg_type} 발송 예약됨'
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'type': msg_type,
|
||||
'sent_count': len(results),
|
||||
'results': results,
|
||||
'message': f'{len(results)}명에게 {msg_type} 발송 완료 (테스트 모드)'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"메시지 발송 오류: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# QR 라벨 인쇄 API
|
||||
# =============================================================================
|
||||
|
||||
@ -400,6 +400,7 @@
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/products" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🔍 제품검색</a>
|
||||
<a href="/admin/members" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">👥 회원검색</a>
|
||||
<a href="/admin/sales-detail" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📋 판매조회</a>
|
||||
<a href="/admin/sales" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🧾 판매내역</a>
|
||||
<a href="/admin/ai-crm" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🤖 AI CRM</a>
|
||||
|
||||
619
backend/templates/admin_members.html
Normal file
619
backend/templates/admin_members.html
Normal file
@ -0,0 +1,619 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>회원 검색 - 청춘약국 CRM</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;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: #f8fafc;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
/* ── 헤더 ── */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #059669 0%, #10b981 50%, #34d399 100%);
|
||||
padding: 28px 32px 24px;
|
||||
color: #fff;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.8);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.header-nav a:hover { color: #fff; }
|
||||
.header h1 {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.header p {
|
||||
font-size: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
/* ── 컨텐츠 ── */
|
||||
.content {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 검색 영역 ── */
|
||||
.search-section {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 14px 18px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 12px;
|
||||
font-size: 16px;
|
||||
font-family: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 4px rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
.search-btn {
|
||||
background: #10b981;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-btn:hover { background: #059669; }
|
||||
.search-hint {
|
||||
margin-top: 12px;
|
||||
font-size: 13px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 결과 카운트 ── */
|
||||
.result-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.result-count {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
.result-count strong {
|
||||
color: #10b981;
|
||||
font-weight: 700;
|
||||
}
|
||||
.send-selected-btn {
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: none;
|
||||
}
|
||||
.send-selected-btn:hover { background: #4f46e5; }
|
||||
.send-selected-btn.active { display: inline-flex; align-items: center; gap: 6px; }
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 14px 16px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px 16px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr:hover { background: #f0fdf4; }
|
||||
tbody tr.selected { background: #dcfce7; }
|
||||
|
||||
.member-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.member-phone {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 13px;
|
||||
color: #059669;
|
||||
}
|
||||
.member-memo {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sms-stop {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* ── 버튼 ── */
|
||||
.btn-send {
|
||||
background: #6366f1;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-send:hover { background: #4f46e5; }
|
||||
.btn-detail {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.btn-detail:hover { background: #e2e8f0; }
|
||||
|
||||
/* ── 체크박스 ── */
|
||||
.checkbox {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
accent-color: #10b981;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-state .icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ── 모달 ── */
|
||||
.modal-overlay {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
.modal-overlay.active { display: flex; }
|
||||
.modal-box {
|
||||
background: #fff;
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.modal-recipient {
|
||||
background: #f8fafc;
|
||||
padding: 12px 16px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.modal-recipient strong {
|
||||
color: #10b981;
|
||||
}
|
||||
.modal-textarea {
|
||||
width: 100%;
|
||||
min-height: 150px;
|
||||
padding: 14px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.modal-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
.char-count {
|
||||
text-align: right;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.msg-type-toggle {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.msg-type-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: 2px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.msg-type-btn.active {
|
||||
border-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
color: #6366f1;
|
||||
}
|
||||
.modal-btns {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.modal-btn {
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
}
|
||||
.modal-btn.cancel { background: #f1f5f9; color: #64748b; }
|
||||
.modal-btn.confirm { background: #6366f1; color: #fff; }
|
||||
.modal-btn.confirm:hover { background: #4f46e5; }
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.search-box { flex-direction: column; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 700px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
|
||||
<a href="/admin/alimtalk">알림톡</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>👥 회원 검색</h1>
|
||||
<p>팜IT3000 회원 검색 · 알림톡/SMS 발송</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색 -->
|
||||
<div class="search-section">
|
||||
<div class="search-box">
|
||||
<input type="text" class="search-input" id="searchInput"
|
||||
placeholder="이름 또는 전화번호로 검색..."
|
||||
onkeypress="if(event.key==='Enter')searchMembers()">
|
||||
<button class="search-btn" onclick="searchMembers()">🔍 검색</button>
|
||||
</div>
|
||||
<div class="search-hint">
|
||||
이름(예: 홍길동) 또는 전화번호(예: 01012345678) 입력
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 헤더 -->
|
||||
<div class="result-header" id="resultHeader" style="display:none;">
|
||||
<div class="result-count">
|
||||
검색 결과: <strong id="resultNum">0</strong>명
|
||||
</div>
|
||||
<button class="send-selected-btn" id="sendSelectedBtn" onclick="openBulkSendModal()">
|
||||
📨 선택 발송 (<span id="selectedCount">0</span>명)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th><input type="checkbox" class="checkbox" id="selectAll" onchange="toggleSelectAll()"></th>
|
||||
<th>이름</th>
|
||||
<th>전화번호</th>
|
||||
<th>메모</th>
|
||||
<th>상태</th>
|
||||
<th>액션</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="membersTableBody">
|
||||
<tr>
|
||||
<td colspan="6" class="empty-state">
|
||||
<div class="icon">👥</div>
|
||||
<p>이름 또는 전화번호로 회원을 검색하세요</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 발송 모달 -->
|
||||
<div class="modal-overlay" id="sendModal" onclick="if(event.target===this)closeSendModal()">
|
||||
<div class="modal-box">
|
||||
<div class="modal-title">📨 메시지 발송</div>
|
||||
<div class="modal-recipient" id="modalRecipient">
|
||||
수신자: <strong>홍길동</strong> (010-1234-5678)
|
||||
</div>
|
||||
<div class="msg-type-toggle">
|
||||
<button class="msg-type-btn active" data-type="sms" onclick="setMsgType('sms')">📱 SMS</button>
|
||||
<button class="msg-type-btn" data-type="alimtalk" onclick="setMsgType('alimtalk')">💬 알림톡</button>
|
||||
</div>
|
||||
<textarea class="modal-textarea" id="messageInput" placeholder="메시지를 입력하세요..." oninput="updateCharCount()"></textarea>
|
||||
<div class="char-count"><span id="charCount">0</span>/90자 (SMS 기준)</div>
|
||||
<div class="modal-btns">
|
||||
<button class="modal-btn cancel" onclick="closeSendModal()">취소</button>
|
||||
<button class="modal-btn confirm" onclick="sendMessage()" id="sendBtn">발송</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let membersData = [];
|
||||
let selectedMembers = new Set();
|
||||
let currentMsgType = 'sms';
|
||||
let sendTargets = [];
|
||||
|
||||
function searchMembers() {
|
||||
const search = document.getElementById('searchInput').value.trim();
|
||||
if (!search) {
|
||||
alert('검색어를 입력하세요');
|
||||
return;
|
||||
}
|
||||
if (search.length < 2) {
|
||||
alert('2글자 이상 입력하세요');
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('membersTableBody');
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><p>검색 중...</p></td></tr>';
|
||||
|
||||
fetch(`/api/members/search?q=${encodeURIComponent(search)}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
membersData = data.items;
|
||||
selectedMembers.clear();
|
||||
updateSelectedUI();
|
||||
document.getElementById('resultHeader').style.display = 'flex';
|
||||
document.getElementById('resultNum').textContent = membersData.length;
|
||||
renderTable();
|
||||
} else {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="empty-state"><p>오류: ${data.error}</p></td></tr>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><p>검색 실패</p></td></tr>';
|
||||
});
|
||||
}
|
||||
|
||||
function formatPhone(phone) {
|
||||
if (!phone) return '-';
|
||||
const p = phone.replace(/[^0-9]/g, '');
|
||||
if (p.length === 11) {
|
||||
return `${p.slice(0,3)}-${p.slice(3,7)}-${p.slice(7)}`;
|
||||
} else if (p.length === 10) {
|
||||
return `${p.slice(0,3)}-${p.slice(3,6)}-${p.slice(6)}`;
|
||||
}
|
||||
return phone;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('membersTableBody');
|
||||
|
||||
if (membersData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="icon">📭</div><p>검색 결과가 없습니다</p></td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = membersData.map((m, idx) => `
|
||||
<tr class="${selectedMembers.has(idx) ? 'selected' : ''}" data-idx="${idx}">
|
||||
<td><input type="checkbox" class="checkbox" ${selectedMembers.has(idx) ? 'checked' : ''} onchange="toggleSelect(${idx})"></td>
|
||||
<td class="member-name">${escapeHtml(m.name)}</td>
|
||||
<td class="member-phone">${formatPhone(m.phone)}</td>
|
||||
<td class="member-memo" title="${escapeHtml(m.memo)}">${escapeHtml(m.memo) || '-'}</td>
|
||||
<td>${m.sms_stop ? '<span class="sms-stop">수신거부</span>' : '<span style="color:#10b981;">정상</span>'}</td>
|
||||
<td>
|
||||
<button class="btn-detail" onclick="viewDetail('${m.cuscode}')">상세</button>
|
||||
<button class="btn-send" onclick="openSendModal(${idx})" ${m.sms_stop ? 'disabled style="opacity:0.5"' : ''}>발송</button>
|
||||
</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function toggleSelect(idx) {
|
||||
if (selectedMembers.has(idx)) {
|
||||
selectedMembers.delete(idx);
|
||||
} else {
|
||||
selectedMembers.add(idx);
|
||||
}
|
||||
updateSelectedUI();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function toggleSelectAll() {
|
||||
const allChecked = document.getElementById('selectAll').checked;
|
||||
if (allChecked) {
|
||||
membersData.forEach((m, idx) => {
|
||||
if (!m.sms_stop) selectedMembers.add(idx);
|
||||
});
|
||||
} else {
|
||||
selectedMembers.clear();
|
||||
}
|
||||
updateSelectedUI();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function updateSelectedUI() {
|
||||
const count = selectedMembers.size;
|
||||
document.getElementById('selectedCount').textContent = count;
|
||||
const btn = document.getElementById('sendSelectedBtn');
|
||||
btn.classList.toggle('active', count > 0);
|
||||
}
|
||||
|
||||
function openSendModal(idx) {
|
||||
const m = membersData[idx];
|
||||
sendTargets = [m];
|
||||
document.getElementById('modalRecipient').innerHTML =
|
||||
`수신자: <strong>${escapeHtml(m.name)}</strong> (${formatPhone(m.phone)})`;
|
||||
document.getElementById('messageInput').value = '';
|
||||
updateCharCount();
|
||||
document.getElementById('sendModal').classList.add('active');
|
||||
}
|
||||
|
||||
function openBulkSendModal() {
|
||||
if (selectedMembers.size === 0) return;
|
||||
|
||||
sendTargets = Array.from(selectedMembers).map(idx => membersData[idx]);
|
||||
const names = sendTargets.slice(0, 3).map(m => m.name).join(', ');
|
||||
const more = sendTargets.length > 3 ? ` 외 ${sendTargets.length - 3}명` : '';
|
||||
|
||||
document.getElementById('modalRecipient').innerHTML =
|
||||
`수신자: <strong>${escapeHtml(names)}${more}</strong> (${sendTargets.length}명)`;
|
||||
document.getElementById('messageInput').value = '';
|
||||
updateCharCount();
|
||||
document.getElementById('sendModal').classList.add('active');
|
||||
}
|
||||
|
||||
function closeSendModal() {
|
||||
document.getElementById('sendModal').classList.remove('active');
|
||||
sendTargets = [];
|
||||
}
|
||||
|
||||
function setMsgType(type) {
|
||||
currentMsgType = type;
|
||||
document.querySelectorAll('.msg-type-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.type === type);
|
||||
});
|
||||
}
|
||||
|
||||
function updateCharCount() {
|
||||
const len = document.getElementById('messageInput').value.length;
|
||||
document.getElementById('charCount').textContent = len;
|
||||
}
|
||||
|
||||
function sendMessage() {
|
||||
const message = document.getElementById('messageInput').value.trim();
|
||||
if (!message) {
|
||||
alert('메시지를 입력하세요');
|
||||
return;
|
||||
}
|
||||
if (sendTargets.length === 0) {
|
||||
alert('수신자가 없습니다');
|
||||
return;
|
||||
}
|
||||
|
||||
const btn = document.getElementById('sendBtn');
|
||||
btn.textContent = '발송 중...';
|
||||
btn.disabled = true;
|
||||
|
||||
const recipients = sendTargets.map(m => ({
|
||||
cuscode: m.cuscode,
|
||||
name: m.name,
|
||||
phone: m.phone
|
||||
}));
|
||||
|
||||
fetch('/api/message/send', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
recipients: recipients,
|
||||
message: message,
|
||||
type: currentMsgType
|
||||
})
|
||||
})
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(`✅ ${data.message}`);
|
||||
closeSendModal();
|
||||
} else {
|
||||
alert('❌ 발송 실패: ' + (data.error || '알 수 없는 오류'));
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
alert('❌ 오류: ' + err.message);
|
||||
})
|
||||
.finally(() => {
|
||||
btn.textContent = '발송';
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function viewDetail(cuscode) {
|
||||
// TODO: 회원 상세 모달
|
||||
alert('상세 보기 기능 준비 중: ' + cuscode);
|
||||
}
|
||||
|
||||
// 페이지 로드 시 검색창 포커스
|
||||
document.getElementById('searchInput').focus();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user