feat: 회원 상세 모달 구현 (마일리지 + POS 이력)
- /api/members/history/<phone>: 통합 이력 조회 API - 마일리지 적립/사용 내역 (SQLite) - POS 구매 이력 (MSSQL - 전화번호→고객코드 매핑) - 세련된 UI: 탭 전환, 거래 카드, 구매 카드 - 상세에서 바로 메시지 발송 가능
This commit is contained in:
parent
a7e96e5efa
commit
7843ca8fcf
140
backend/app.py
140
backend/app.py
@ -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
|
||||
# =============================================================================
|
||||
|
||||
@ -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');
|
||||
}
|
||||
|
||||
// 페이지 로드 시 검색창 포커스
|
||||
|
||||
Loading…
Reference in New Issue
Block a user