feat: 회원 상세 모달 구현 (마일리지 + POS 이력)

- /api/members/history/<phone>: 통합 이력 조회 API
- 마일리지 적립/사용 내역 (SQLite)
- POS 구매 이력 (MSSQL - 전화번호→고객코드 매핑)
- 세련된 UI: 탭 전환, 거래 카드, 구매 카드
- 상세에서 바로 메시지 발송 가능
This commit is contained in:
thug0bin 2026-02-27 15:08:09 +09:00
parent a7e96e5efa
commit 7843ca8fcf
2 changed files with 503 additions and 4 deletions

View File

@ -3004,6 +3004,146 @@ def api_member_detail(cuscode):
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/members/history/<phone>')
def api_member_history(phone):
"""
회원 구매 이력 통합 조회 API
- 마일리지 적립/사용 내역 (SQLite)
- POS 구매 이력 (MSSQL)
"""
try:
# 전화번호 정규화
phone = phone.replace('-', '').replace(' ', '')
result = {
'success': True,
'phone': phone,
'mileage': None,
'purchases': []
}
# 1. 마일리지 내역 조회 (SQLite)
try:
sqlite_conn = db_manager.get_sqlite_connection()
cursor = sqlite_conn.cursor()
# 사용자 정보 조회
cursor.execute("""
SELECT id, nickname, phone, mileage_balance, created_at
FROM users WHERE phone = ?
""", (phone,))
user = cursor.fetchone()
if user:
user_id = user['id']
# 적립/사용 내역 조회
cursor.execute("""
SELECT
ml.points, ml.balance_after, ml.reason,
ml.description, ml.transaction_id, ml.created_at
FROM mileage_ledger ml
WHERE ml.user_id = ?
ORDER BY ml.created_at DESC
LIMIT 50
""", (user_id,))
transactions = cursor.fetchall()
result['mileage'] = {
'user_id': user_id,
'name': user['nickname'] or '',
'phone': user['phone'],
'balance': user['mileage_balance'] or 0,
'member_since': user['created_at'],
'transactions': [{
'points': t['points'],
'balance_after': t['balance_after'],
'reason': t['reason'],
'description': t['description'],
'transaction_id': t['transaction_id'],
'created_at': t['created_at']
} for t in transactions]
}
except Exception as e:
logging.warning(f"마일리지 조회 실패: {e}")
# 2. POS 구매 이력 조회 (MSSQL)
try:
base_session = db_manager.get_session('PM_BASE')
pres_session = db_manager.get_session('PM_PRES')
drug_session = db_manager.get_session('PM_DRUG')
# 전화번호로 고객코드 조회
cuscode_query = text("""
SELECT TOP 1 CUSCODE, PANAME
FROM CD_PERSON
WHERE REPLACE(REPLACE(PHONE, '-', ''), ' ', '') = :phone
OR REPLACE(REPLACE(TEL_NO, '-', ''), ' ', '') = :phone
OR REPLACE(REPLACE(PHONE2, '-', ''), ' ', '') = :phone
""")
cus_row = base_session.execute(cuscode_query, {'phone': phone}).fetchone()
if cus_row:
cuscode = cus_row.CUSCODE
result['pos_customer'] = {
'cuscode': cuscode,
'name': cus_row.PANAME
}
# 구매 이력 조회 (최근 30일)
purchase_query = text("""
SELECT
M.SL_NO_order as order_no,
M.SL_DT_appl as order_date,
M.SL_MY_total as total_amount,
M.SL_MY_discount as discount
FROM SALE_MAIN M
WHERE M.SL_CD_custom = :cuscode
AND M.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -30, GETDATE()), 112)
ORDER BY M.SL_DT_appl DESC, M.SL_NO_order DESC
""")
orders = pres_session.execute(purchase_query, {'cuscode': cuscode}).fetchall()
purchases = []
for order in orders[:20]: # 최대 20건
# 주문 상세 (품목)
items_query = text("""
SELECT
S.DrugCode,
G.GoodsName,
S.QUAN as quantity,
S.SL_TOTAL_PRICE as price
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.SL_NO_order = :order_no
""")
items = pres_session.execute(items_query, {'order_no': order.order_no}).fetchall()
purchases.append({
'order_no': order.order_no,
'date': order.order_date,
'total': float(order.total_amount) if order.total_amount else 0,
'discount': float(order.discount) if order.discount else 0,
'items': [{
'drug_code': item.DrugCode,
'name': item.GoodsName or '알 수 없음',
'quantity': float(item.quantity) if item.quantity else 1,
'price': float(item.price) if item.price else 0
} for item in items]
})
result['purchases'] = purchases
except Exception as e:
logging.warning(f"POS 구매 이력 조회 실패: {e}")
return jsonify(result)
except Exception as e:
logging.error(f"회원 이력 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
# =============================================================================
# 알림톡/SMS 발송 API
# =============================================================================

View File

@ -320,6 +320,194 @@
.modal-btn.confirm { background: #6366f1; color: #fff; }
.modal-btn.confirm:hover { background: #4f46e5; }
/* ── 회원 상세 모달 ── */
.detail-modal {
max-width: 600px;
max-height: 85vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.detail-header {
background: linear-gradient(135deg, #059669, #10b981);
margin: -24px -24px 0;
padding: 24px;
border-radius: 16px 16px 0 0;
color: #fff;
}
.detail-name {
font-size: 22px;
font-weight: 700;
margin-bottom: 6px;
}
.detail-phone {
font-size: 15px;
opacity: 0.9;
}
.detail-balance {
margin-top: 12px;
padding: 12px 16px;
background: rgba(255,255,255,0.15);
border-radius: 10px;
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-balance-label {
font-size: 13px;
opacity: 0.9;
}
.detail-balance-value {
font-size: 24px;
font-weight: 700;
}
.detail-tabs {
display: flex;
border-bottom: 2px solid #e2e8f0;
margin: 20px -24px 0;
padding: 0 24px;
}
.detail-tab {
padding: 12px 20px;
font-size: 14px;
font-weight: 600;
color: #64748b;
cursor: pointer;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
transition: all 0.2s;
}
.detail-tab:hover { color: #10b981; }
.detail-tab.active {
color: #10b981;
border-bottom-color: #10b981;
}
.detail-content {
flex: 1;
overflow-y: auto;
padding: 20px 0;
max-height: 400px;
}
.detail-empty {
text-align: center;
padding: 40px;
color: #94a3b8;
}
.detail-loading {
text-align: center;
padding: 40px;
color: #64748b;
}
/* 거래 카드 */
.tx-card {
background: #f8fafc;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-left: 4px solid #10b981;
}
.tx-card.negative { border-left-color: #f59e0b; }
.tx-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.tx-date {
font-size: 12px;
color: #64748b;
}
.tx-points {
font-size: 18px;
font-weight: 700;
color: #10b981;
}
.tx-points.negative { color: #f59e0b; }
.tx-desc {
font-size: 13px;
color: #475569;
}
.tx-items {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed #e2e8f0;
}
.tx-item {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #64748b;
padding: 4px 0;
}
.tx-item-name {
flex: 1;
}
.tx-item-qty {
color: #94a3b8;
margin: 0 12px;
}
.tx-item-price {
font-weight: 500;
color: #475569;
}
/* 구매 카드 */
.purchase-card {
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
}
.purchase-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.purchase-date {
font-size: 13px;
color: #64748b;
}
.purchase-total {
font-size: 16px;
font-weight: 700;
color: #1e293b;
}
.purchase-items {
border-top: 1px solid #f1f5f9;
padding-top: 12px;
}
.purchase-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 6px 0;
font-size: 13px;
}
.purchase-item-name {
flex: 1;
color: #334155;
}
.purchase-item-qty {
color: #94a3b8;
margin: 0 16px;
font-size: 12px;
}
.purchase-item-price {
color: #64748b;
font-weight: 500;
}
.detail-footer {
padding-top: 16px;
border-top: 1px solid #e2e8f0;
display: flex;
gap: 12px;
justify-content: flex-end;
}
/* ── 반응형 ── */
@media (max-width: 768px) {
.search-box { flex-direction: column; }
@ -410,6 +598,31 @@
</div>
</div>
<!-- 회원 상세 모달 -->
<div class="modal-overlay" id="detailModal" onclick="if(event.target===this)closeDetailModal()">
<div class="modal-box detail-modal">
<div class="detail-header">
<div class="detail-name" id="detailName">-</div>
<div class="detail-phone" id="detailPhone">-</div>
<div class="detail-balance">
<span class="detail-balance-label">💰 적립 포인트</span>
<span class="detail-balance-value" id="detailBalance">0P</span>
</div>
</div>
<div class="detail-tabs">
<div class="detail-tab active" data-tab="mileage" onclick="switchDetailTab('mileage')">📊 적립 내역</div>
<div class="detail-tab" data-tab="purchase" onclick="switchDetailTab('purchase')">🛒 구매 이력</div>
</div>
<div class="detail-content" id="detailContent">
<div class="detail-loading">데이터를 불러오는 중...</div>
</div>
<div class="detail-footer">
<button class="modal-btn cancel" onclick="closeDetailModal()">닫기</button>
<button class="modal-btn confirm" onclick="openSendFromDetail()">📨 메시지 발송</button>
</div>
</div>
</div>
<script>
let membersData = [];
let selectedMembers = new Set();
@ -476,7 +689,7 @@
<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-detail" onclick="viewDetail(${idx})">상세</button>
<button class="btn-send" onclick="openSendModal(${idx})" ${m.sms_stop ? 'disabled style="opacity:0.5"' : ''}>발송</button>
</td>
</tr>
@ -607,9 +820,155 @@
});
}
function viewDetail(cuscode) {
// TODO: 회원 상세 모달
alert('상세 보기 기능 준비 중: ' + cuscode);
// ── 회원 상세 모달 ──
let detailData = null;
let currentDetailTab = 'mileage';
let currentDetailMember = null;
function viewDetail(idx) {
currentDetailMember = membersData[idx];
const phone = currentDetailMember.phone.replace(/-/g, '').replace(/ /g, '');
// 모달 열기
document.getElementById('detailModal').classList.add('active');
document.getElementById('detailName').textContent = currentDetailMember.name || '이름 없음';
document.getElementById('detailPhone').textContent = formatPhone(currentDetailMember.phone);
document.getElementById('detailBalance').textContent = '로딩...';
document.getElementById('detailContent').innerHTML = '<div class="detail-loading">데이터를 불러오는 중...</div>';
// 데이터 로드
fetch(`/api/members/history/${phone}`)
.then(res => res.json())
.then(data => {
if (data.success) {
detailData = data;
// 잔액 표시
if (data.mileage) {
document.getElementById('detailBalance').textContent =
data.mileage.balance.toLocaleString() + 'P';
} else {
document.getElementById('detailBalance').textContent = '미가입';
}
// 탭 콘텐츠 렌더링
renderDetailTab();
} else {
document.getElementById('detailContent').innerHTML =
`<div class="detail-empty">데이터 조회 실패: ${data.error}</div>`;
}
})
.catch(err => {
document.getElementById('detailContent').innerHTML =
`<div class="detail-empty">오류: ${err.message}</div>`;
});
}
function closeDetailModal() {
document.getElementById('detailModal').classList.remove('active');
detailData = null;
currentDetailMember = null;
}
function switchDetailTab(tab) {
currentDetailTab = tab;
document.querySelectorAll('.detail-tab').forEach(t => {
t.classList.toggle('active', t.dataset.tab === tab);
});
renderDetailTab();
}
function renderDetailTab() {
const content = document.getElementById('detailContent');
if (!detailData) {
content.innerHTML = '<div class="detail-empty">데이터가 없습니다</div>';
return;
}
if (currentDetailTab === 'mileage') {
renderMileageTab(content);
} else {
renderPurchaseTab(content);
}
}
function renderMileageTab(container) {
if (!detailData.mileage || !detailData.mileage.transactions || detailData.mileage.transactions.length === 0) {
container.innerHTML = '<div class="detail-empty">📭 적립 내역이 없습니다</div>';
return;
}
const txs = detailData.mileage.transactions;
container.innerHTML = txs.map(tx => {
const isPositive = tx.points > 0;
const date = tx.created_at ? new Date(tx.created_at).toLocaleString('ko-KR', {
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
}) : '';
return `
<div class="tx-card ${isPositive ? '' : 'negative'}">
<div class="tx-header">
<div class="tx-date">📅 ${date}</div>
<div class="tx-points ${isPositive ? '' : 'negative'}">
${isPositive ? '+' : ''}${tx.points.toLocaleString()}P
</div>
</div>
<div class="tx-desc">${escapeHtml(tx.description || tx.reason || '')}</div>
</div>
`;
}).join('');
}
function renderPurchaseTab(container) {
if (!detailData.purchases || detailData.purchases.length === 0) {
container.innerHTML = '<div class="detail-empty">📭 구매 이력이 없습니다<br><small style="color:#94a3b8;">최근 30일 내역만 표시됩니다</small></div>';
return;
}
const purchases = detailData.purchases;
container.innerHTML = purchases.map(p => {
// 날짜 포맷
const dateStr = p.date || '';
let formattedDate = dateStr;
if (dateStr.length === 8) {
formattedDate = `${dateStr.slice(0,4)}-${dateStr.slice(4,6)}-${dateStr.slice(6,8)}`;
}
// 품목 렌더링
const itemsHtml = (p.items || []).map(item => `
<div class="purchase-item">
<span class="purchase-item-name">${escapeHtml(item.name)}</span>
<span class="purchase-item-qty">x${item.quantity}</span>
<span class="purchase-item-price">${item.price.toLocaleString()}원</span>
</div>
`).join('');
return `
<div class="purchase-card">
<div class="purchase-header">
<span class="purchase-date">📅 ${formattedDate}</span>
<span class="purchase-total">${p.total.toLocaleString()}원</span>
</div>
${p.items && p.items.length > 0 ? `
<div class="purchase-items">${itemsHtml}</div>
` : ''}
</div>
`;
}).join('');
}
function openSendFromDetail() {
if (!currentDetailMember) return;
closeDetailModal();
// 발송 모달 열기
sendTargets = [currentDetailMember];
document.getElementById('modalRecipient').innerHTML =
`수신자: <strong>${escapeHtml(currentDetailMember.name)}</strong> (${formatPhone(currentDetailMember.phone)})`;
document.getElementById('messageInput').value = '';
updateCharCount();
document.getElementById('sendModal').classList.add('active');
}
// 페이지 로드 시 검색창 포커스