feat: POS 실시간 판매 조회 웹 페이지 (Qt GUI 웹 버전)

This commit is contained in:
thug0bin 2026-03-02 15:26:51 +09:00
parent 52a4f69abc
commit e37659dc04
5 changed files with 1187 additions and 0 deletions

View File

@ -4851,6 +4851,269 @@ def get_breeds(species):
return jsonify({'success': True, 'breeds': ['기타']})
# ═══════════════════════════════════════════════════════════════════════════════
# POS 실시간 판매 조회 (Qt GUI 웹 버전)
# ═══════════════════════════════════════════════════════════════════════════════
@app.route('/admin/pos-live')
def admin_pos_live():
"""POS 실시간 판매 조회 페이지 (Qt GUI 웹 버전)"""
return render_template('admin_pos_live.html')
@app.route('/api/admin/pos-live')
def api_admin_pos_live():
"""
실시간 판매 내역 API (Qt GUI와 동일한 쿼리)
- MSSQL: SALE_MAIN, CD_SUNAB, SALE_SUB
- SQLite: claim_tokens, users, mileage_ledger
"""
date_str = request.args.get('date')
if not date_str:
date_str = datetime.now().strftime('%Y%m%d')
mssql_conn = None
try:
# MSSQL 연결
mssql_engine = db_manager.get_engine('PM_PRES')
mssql_conn = mssql_engine.raw_connection()
mssql_cursor = mssql_conn.cursor()
# SQLite 연결
sqlite_conn = db_manager.get_sqlite_connection()
sqlite_cursor = sqlite_conn.cursor()
# 메인 쿼리: SALE_MAIN + CD_SUNAB 조인
query = """
SELECT
M.SL_NO_order,
M.InsertTime,
M.SL_MY_sale,
ISNULL(M.SL_NM_custom, '[비고객]') AS customer_name,
ISNULL(S.card_total, 0) AS card_total,
ISNULL(S.cash_total, 0) AS cash_total,
ISNULL(M.SL_MY_total, 0) AS total_amount,
ISNULL(M.SL_MY_discount, 0) AS discount,
S.cash_receipt_mode,
S.cash_receipt_num
FROM SALE_MAIN M
OUTER APPLY (
SELECT TOP 1
ISNULL(ETC_CARD, 0) + ISNULL(OTC_CARD, 0) AS card_total,
ISNULL(ETC_CASH, 0) + ISNULL(OTC_CASH, 0) AS cash_total,
nCASHINMODE AS cash_receipt_mode,
nAPPROVAL_NUM AS cash_receipt_num
FROM CD_SUNAB
WHERE PRESERIAL = M.SL_NO_order
) S
WHERE M.SL_DT_appl = ?
ORDER BY M.InsertTime DESC
"""
mssql_cursor.execute(query, date_str)
rows = mssql_cursor.fetchall()
sales_list = []
total_sales = 0
for row in rows:
order_no, insert_time, sale_amount, customer, card_total, cash_total, total_amount, discount, cash_receipt_mode, cash_receipt_num = row
# 품목 수 조회 (SALE_SUB)
mssql_cursor.execute("""
SELECT COUNT(*) FROM SALE_SUB WHERE SL_NO_order = ?
""", order_no)
item_count_row = mssql_cursor.fetchone()
item_count = item_count_row[0] if item_count_row else 0
# SQLite에서 QR 발행 여부 확인
sqlite_cursor.execute("""
SELECT id FROM claim_tokens WHERE transaction_id = ?
""", (order_no,))
qr_record = sqlite_cursor.fetchone()
qr_issued = bool(qr_record)
# SQLite에서 적립 사용자 조회
sqlite_cursor.execute("""
SELECT u.nickname, u.phone, ct.claimable_points
FROM claim_tokens ct
LEFT JOIN users u ON ct.claimed_by_user_id = u.id
WHERE ct.transaction_id = ? AND ct.claimed_at IS NOT NULL
""", (order_no,))
claimed_user = sqlite_cursor.fetchone()
# 적립 사용자 정보 분리
if claimed_user and claimed_user['nickname'] and claimed_user['phone']:
claimed_name = claimed_user['nickname']
claimed_phone = claimed_user['phone']
claimed_points = claimed_user['claimable_points']
else:
claimed_name = ""
claimed_phone = ""
claimed_points = 0
# 결제수단 판별
card_amt = float(card_total) if card_total else 0.0
cash_amt = float(cash_total) if cash_total else 0.0
has_cash_receipt = (
str(cash_receipt_mode or '').strip() == '1'
and str(cash_receipt_num or '').strip() != ''
)
if card_amt > 0 and cash_amt > 0:
pay_method = '카드+현금'
elif card_amt > 0:
pay_method = '카드'
elif cash_amt > 0:
pay_method = '현영' if has_cash_receipt else '현금'
else:
pay_method = ''
paid = (card_amt + cash_amt) > 0
disc_amt = float(discount) if discount else 0.0
total_amt = float(total_amount) if total_amount else 0.0
sale_amt = float(sale_amount) if sale_amount else 0.0
total_sales += sale_amt
sales_list.append({
'order_no': order_no,
'time': insert_time.strftime('%H:%M') if insert_time else '--:--',
'amount': sale_amt,
'discount': disc_amt,
'total_before_dc': total_amt,
'customer': customer,
'pay_method': pay_method,
'paid': paid,
'item_count': item_count,
'claimed_name': claimed_name,
'claimed_phone': claimed_phone,
'claimed_points': claimed_points,
'qr_issued': qr_issued
})
return jsonify({
'success': True,
'date': date_str,
'count': len(sales_list),
'total_sales': total_sales,
'sales': sales_list
})
except Exception as e:
logging.error(f"POS 실시간 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
if mssql_conn:
mssql_conn.close()
@app.route('/api/admin/pos-live/detail/<order_no>')
def api_admin_pos_live_detail(order_no):
"""
판매 상세 조회 API (SALE_SUB 품목 목록)
"""
mssql_conn = None
try:
mssql_engine = db_manager.get_engine('PM_PRES')
mssql_conn = mssql_engine.raw_connection()
cursor = mssql_conn.cursor()
# 품목 상세 조회
cursor.execute("""
SELECT
S.DrugCode AS drug_code,
ISNULL(G.GoodsName, '(약품명 없음)') AS product_name,
S.SL_NM_item AS quantity,
S.SL_NM_cost_a AS unit_price,
S.SL_TOTAL_PRICE AS total_price
FROM SALE_SUB S
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
WHERE S.SL_NO_order = ?
ORDER BY S.DrugCode
""", order_no)
rows = cursor.fetchall()
items = []
for row in rows:
items.append({
'drug_code': row[0],
'product_name': row[1],
'quantity': int(row[2]) if row[2] else 0,
'unit_price': float(row[3]) if row[3] else 0,
'total_price': float(row[4]) if row[4] else 0
})
return jsonify({
'success': True,
'order_no': order_no,
'items': items
})
except Exception as e:
logging.error(f"판매 상세 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
finally:
if mssql_conn:
mssql_conn.close()
@app.route('/api/admin/user-mileage/<phone>')
def api_admin_user_mileage(phone):
"""
회원 마일리지 내역 API
"""
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 not user:
return jsonify({'success': False, 'error': '등록되지 않은 회원입니다.'}), 404
# 적립 내역 조회
cursor.execute("""
SELECT points, balance_after, reason, description, created_at
FROM mileage_ledger
WHERE user_id = ?
ORDER BY created_at DESC
LIMIT 50
""", (user['id'],))
transactions = cursor.fetchall()
history = []
for tx in transactions:
history.append({
'points': tx['points'],
'balance_after': tx['balance_after'],
'reason': tx['reason'],
'description': tx['description'],
'created_at': tx['created_at']
})
return jsonify({
'success': True,
'user': {
'id': user['id'],
'nickname': user['nickname'],
'phone': user['phone'],
'mileage_balance': user['mileage_balance'],
'created_at': user['created_at']
},
'history': history
})
except Exception as e:
logging.error(f"회원 마일리지 조회 오류: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
if __name__ == '__main__':
import os

View File

@ -0,0 +1,68 @@
# -*- coding: utf-8 -*-
"""APC 매칭 성능 측정"""
import sys, io, time
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import get_db_session
from sqlalchemy import text, create_engine
print('=== APC 매칭 성능 측정 ===\n')
# 1. MSSQL: 동물약 + APC 조회
start = time.time()
session = get_db_session('PM_DRUG')
result = session.execute(text("""
SELECT G.DrugCode, G.GoodsName,
(SELECT TOP 1 U.CD_CD_BARCODE FROM CD_ITEM_UNIT_MEMBER U
WHERE U.DRUGCODE = G.DrugCode AND U.CD_CD_BARCODE LIKE '023%') AS APC_CODE
FROM CD_GOODS G
WHERE G.POS_BOON = '010103' AND G.GoodsSelCode = 'B'
"""))
mssql_rows = list(result)
no_apc = [r for r in mssql_rows if not r.APC_CODE]
has_apc = [r for r in mssql_rows if r.APC_CODE]
mssql_time = time.time() - start
print(f'1. MSSQL 동물약 조회: {mssql_time:.3f}')
print(f' - 총 제품: {len(mssql_rows)}')
print(f' - APC 있음: {len(has_apc)}개 ✅')
print(f' - APC 없음: {len(no_apc)}개 ❌')
# 2. PostgreSQL 연결 + 매칭 검색
pg = create_engine('postgresql://admin:trajet6640@192.168.0.87:5432/apdb_master').connect()
# 샘플 매칭 테스트
sample_count = min(5, len(no_apc))
start = time.time()
match_count = 0
for drug in no_apc[:sample_count]:
search_name = drug.GoodsName.replace('(판)', '').split('(')[0].strip()
res = pg.execute(text("""
SELECT apc, product_name FROM apc
WHERE product_name ILIKE :p LIMIT 5
"""), {'p': f'%{search_name}%'})
if list(res):
match_count += 1
pg_search_time = time.time() - start
per_search = pg_search_time / sample_count if sample_count > 0 else 0
print(f'\n2. PostgreSQL 매칭 검색: {pg_search_time:.3f}초 ({sample_count}개 샘플)')
print(f' - 건당 소요: {per_search*1000:.1f}ms')
print(f' - 매칭 성공: {match_count}/{sample_count}')
print(f' - 예상 전체: {per_search * len(no_apc):.1f}초 ({len(no_apc)}개)')
# 3. APC 테이블 통계
start = time.time()
total_apc = pg.execute(text("SELECT COUNT(*) FROM apc")).scalar()
with_image = pg.execute(text("SELECT COUNT(*) FROM apc WHERE image_url1 IS NOT NULL AND image_url1 != ''")).scalar()
pg.close()
print(f'\n3. APDB 통계:')
print(f' - 전체 APC: {total_apc:,}')
print(f' - 이미지 있음: {with_image:,}개 ({with_image/total_apc*100:.1f}%)')
# 4. CD_ITEM_UNIT_MEMBER 구조 확인
print(f'\n4. 현재 APC 매핑 상태:')
for r in has_apc[:5]:
print(f'{r.GoodsName[:25]:<25}{r.APC_CODE}')
session.close()
print('\n=== 측정 완료 ===')

View File

@ -0,0 +1,23 @@
# -*- coding: utf-8 -*-
"""pets 테이블 마이그레이션 테스트"""
import sys, io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
sys.path.insert(0, 'c:\\Users\\청춘약국\\source\\pharmacy-pos-qr-system\\backend')
from db.dbsetup import db_manager
# SQLite 연결 (마이그레이션 자동 실행)
conn = db_manager.get_sqlite_connection()
cursor = conn.cursor()
# pets 테이블 확인
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='pets'")
if cursor.fetchone():
print('✅ pets 테이블 생성 완료')
cursor.execute('PRAGMA table_info(pets)')
columns = cursor.fetchall()
print('\n컬럼 목록:')
for col in columns:
print(f' - {col[1]} ({col[2]})')
else:
print('❌ pets 테이블 없음')

View File

@ -98,6 +98,89 @@ class KakaoAPIClient:
'error_description': f'Invalid JSON response: {e}'
}
def refresh_access_token(self, refresh_token: str) -> Tuple[bool, Dict[str, Any]]:
"""Refresh Token으로 Access Token 갱신"""
url = f"{self.auth_base_url}/oauth/token"
data = {
'grant_type': 'refresh_token',
'client_id': self.client_id,
'refresh_token': refresh_token,
}
if self.client_secret:
data['client_secret'] = self.client_secret
try:
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
response = self.session.post(url, data=data, headers=headers)
logger.info(f"카카오 토큰 갱신 응답 상태: {response.status_code}")
response.raise_for_status()
token_data = response.json()
if 'expires_in' in token_data:
expires_at = datetime.now() + timedelta(seconds=token_data['expires_in'])
token_data['expires_at'] = expires_at.isoformat()
return True, token_data
except requests.exceptions.RequestException as e:
logger.error(f"카카오 토큰 갱신 실패: {e}")
error_details = {
'error': 'token_refresh_failed',
'error_description': f'Failed to refresh access token: {e}'
}
try:
if hasattr(e, 'response') and e.response is not None:
kakao_error = e.response.json()
logger.error(f"카카오 API 오류: {kakao_error}")
error_details.update(kakao_error)
except Exception:
pass
return False, error_details
def get_user_info_with_refresh(
self,
access_token: str,
refresh_token: str,
token_expires_at: str = None
) -> Tuple[bool, Dict[str, Any], Dict[str, Any]]:
"""저장된 토큰으로 사용자 정보 조회 (만료 시 자동 갱신)
Returns:
(성공여부, 사용자정보/에러, 갱신된 토큰 데이터 또는 dict)
"""
new_token_data = {}
# 만료 확인: 5분 이내면 미리 갱신
if token_expires_at:
try:
expires = datetime.fromisoformat(token_expires_at)
if datetime.now() >= expires - timedelta(minutes=5):
logger.info("Access token 만료 임박, 갱신 시도")
success, refreshed = self.refresh_access_token(refresh_token)
if success:
access_token = refreshed['access_token']
new_token_data = refreshed
else:
return False, refreshed, {}
except (ValueError, TypeError) as e:
logger.warning(f"token_expires_at 파싱 실패, 기존 토큰으로 시도: {e}")
# 사용자 정보 조회
success, user_info = self.get_user_info(access_token)
if not success and refresh_token:
# 실패 시 갱신 후 재시도
logger.info("사용자 정보 조회 실패, 토큰 갱신 후 재시도")
refresh_ok, refreshed = self.refresh_access_token(refresh_token)
if refresh_ok:
access_token = refreshed['access_token']
new_token_data = refreshed
success, user_info = self.get_user_info(access_token)
return success, user_info, new_token_data
def get_user_info(self, access_token: str) -> Tuple[bool, Dict[str, Any]]:
"""Access Token으로 사용자 정보 조회"""
url = f"{self.api_base_url}/v2/user/me"

View File

@ -0,0 +1,750 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>실시간 판매 조회 - 청춘약국</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, #6366f1 0%, #8b5cf6 50%, #a78bfa 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: 1400px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 컨트롤 영역 ── */
.control-section {
background: #fff;
border-radius: 14px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid #e2e8f0;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.control-left {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.control-right {
display: flex;
gap: 12px;
align-items: center;
}
.date-input {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 15px;
font-family: 'JetBrains Mono', monospace;
width: 160px;
transition: all 0.2s;
}
.date-input:focus {
outline: none;
border-color: #8b5cf6;
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #8b5cf6;
color: #fff;
}
.btn-primary:hover { background: #7c3aed; }
.btn-secondary {
background: #f1f5f9;
color: #475569;
}
.btn-secondary:hover { background: #e2e8f0; }
.btn-success {
background: #10b981;
color: #fff;
}
.btn-success:hover { background: #059669; }
.auto-refresh {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #64748b;
}
.auto-refresh input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #8b5cf6;
}
/* ── 통계 카드 ── */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: #fff;
border-radius: 14px;
padding: 20px 24px;
border: 1px solid #e2e8f0;
}
.stat-label {
font-size: 13px;
color: #94a3b8;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
color: #1e293b;
}
.stat-value.highlight {
color: #8b5cf6;
}
/* ── 테이블 ── */
.table-section {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.table-header {
padding: 16px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-size: 16px;
font-weight: 600;
}
.table-count {
font-size: 14px;
color: #64748b;
}
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
thead {
background: #f8fafc;
}
th {
padding: 14px 16px;
text-align: left;
font-weight: 600;
color: #64748b;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
th.center, td.center {
text-align: center;
}
th.right, td.right {
text-align: right;
}
td {
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tr:hover {
background: #faf5ff;
cursor: pointer;
}
tr.selected {
background: #ede9fe;
}
.order-no {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: #64748b;
}
.time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.amount {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: #1e293b;
}
.customer {
font-weight: 500;
}
.customer.non-member {
color: #94a3b8;
}
/* 결제수단 뱃지 */
.pay-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.pay-card { background: #dbeafe; color: #1d4ed8; }
.pay-cash { background: #dcfce7; color: #15803d; }
.pay-receipt { background: #fef3c7; color: #b45309; }
.pay-mixed { background: #fae8ff; color: #a21caf; }
.pay-none { background: #f1f5f9; color: #94a3b8; }
/* 상태 아이콘 */
.status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
font-size: 14px;
}
.status-icon.qr-yes { background: #dcfce7; }
.status-icon.qr-no { background: #fee2e2; }
.status-icon.claimed { background: #dbeafe; }
.claimed-info {
font-size: 12px;
color: #6366f1;
font-weight: 500;
}
.claimed-phone {
font-size: 11px;
color: #94a3b8;
font-family: 'JetBrains Mono', monospace;
}
/* ── 상세 패널 ── */
.detail-panel {
position: fixed;
top: 0;
right: -500px;
width: 480px;
height: 100vh;
background: #fff;
box-shadow: -4px 0 24px rgba(0,0,0,0.1);
transition: right 0.3s ease;
z-index: 1000;
display: flex;
flex-direction: column;
}
.detail-panel.open {
right: 0;
}
.detail-header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-title {
font-size: 18px;
font-weight: 700;
}
.detail-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #64748b;
padding: 4px;
}
.detail-close:hover { color: #1e293b; }
.detail-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.detail-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.detail-info-item {
background: #f8fafc;
padding: 14px 16px;
border-radius: 10px;
}
.detail-info-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
}
.detail-info-value {
font-size: 16px;
font-weight: 600;
}
.detail-items-title {
font-size: 14px;
font-weight: 600;
color: #64748b;
margin-bottom: 12px;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
}
.detail-item:last-child {
border-bottom: none;
}
.detail-item-name {
font-weight: 500;
flex: 1;
}
.detail-item-qty {
color: #64748b;
margin: 0 16px;
font-family: 'JetBrains Mono', monospace;
}
.detail-item-price {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
/* ── 오버레이 ── */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.3);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.overlay.visible {
opacity: 1;
visibility: visible;
}
/* ── 로딩 ── */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 60px;
color: #94a3b8;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* ── 반응형 ── */
@media (max-width: 768px) {
.control-section {
flex-direction: column;
align-items: stretch;
}
.control-left, .control-right {
justify-content: center;
}
.stats-row {
grid-template-columns: 1fr 1fr;
}
.detail-panel {
width: 100%;
right: -100%;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<a href="/admin/pos">월간 매출</a>
</div>
<h1>📊 실시간 판매 조회</h1>
<p>POS 판매 내역을 실시간으로 확인합니다 (Qt GUI 웹 버전)</p>
</div>
<div class="content">
<!-- 컨트롤 영역 -->
<div class="control-section">
<div class="control-left">
<input type="date" id="dateInput" class="date-input">
<button class="btn btn-primary" onclick="loadSales()">조회</button>
<button class="btn btn-secondary" onclick="setToday()">오늘</button>
</div>
<div class="control-right">
<label class="auto-refresh">
<input type="checkbox" id="autoRefresh">
자동 새로고침 (30초)
</label>
<button class="btn btn-success" onclick="loadSales()">🔄 새로고침</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">총 판매 건수</div>
<div class="stat-value" id="totalCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">총 매출액</div>
<div class="stat-value highlight" id="totalSales">-</div>
</div>
<div class="stat-card">
<div class="stat-label">QR 발행</div>
<div class="stat-value" id="qrCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">적립 완료</div>
<div class="stat-value" id="claimedCount">-</div>
</div>
</div>
<!-- 테이블 -->
<div class="table-section">
<div class="table-header">
<span class="table-title">판매 내역</span>
<span class="table-count" id="tableCount"></span>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>시간</th>
<th class="right">금액</th>
<th>고객</th>
<th class="center">결제</th>
<th class="center">품목</th>
<th class="center">QR</th>
<th>적립</th>
</tr>
</thead>
<tbody id="salesTable">
<tr>
<td colspan="7">
<div class="loading">
<div class="spinner"></div>
데이터 로딩 중...
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- 상세 패널 -->
<div class="overlay" id="overlay" onclick="closeDetail()"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<span class="detail-title">판매 상세</span>
<button class="detail-close" onclick="closeDetail()">×</button>
</div>
<div class="detail-content" id="detailContent">
<!-- 동적 로드 -->
</div>
</div>
<script>
let salesData = [];
let refreshInterval = null;
// 초기화
document.addEventListener('DOMContentLoaded', () => {
setToday();
loadSales();
// 자동 새로고침 토글
document.getElementById('autoRefresh').addEventListener('change', (e) => {
if (e.target.checked) {
refreshInterval = setInterval(loadSales, 30000);
} else {
clearInterval(refreshInterval);
refreshInterval = null;
}
});
});
function setToday() {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
document.getElementById('dateInput').value = `${yyyy}-${mm}-${dd}`;
}
async function loadSales() {
const dateInput = document.getElementById('dateInput').value;
const dateStr = dateInput.replace(/-/g, '');
document.getElementById('salesTable').innerHTML = `
<tr><td colspan="7">
<div class="loading"><div class="spinner"></div>데이터 로딩 중...</div>
</td></tr>
`;
try {
const res = await fetch(`/api/admin/pos-live?date=${dateStr}`);
const data = await res.json();
if (!data.success) {
throw new Error(data.error || '조회 실패');
}
salesData = data.sales;
renderTable(data);
updateStats(data);
} catch (err) {
document.getElementById('salesTable').innerHTML = `
<tr><td colspan="7">
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div>오류: ${err.message}</div>
</div>
</td></tr>
`;
}
}
function updateStats(data) {
document.getElementById('totalCount').textContent = data.count.toLocaleString();
document.getElementById('totalSales').textContent = '₩' + Math.floor(data.total_sales).toLocaleString();
const qrCount = data.sales.filter(s => s.qr_issued).length;
const claimedCount = data.sales.filter(s => s.claimed_name).length;
document.getElementById('qrCount').textContent = qrCount + ' / ' + data.count;
document.getElementById('claimedCount').textContent = claimedCount;
document.getElementById('tableCount').textContent = `${data.count}건`;
}
function renderTable(data) {
if (data.sales.length === 0) {
document.getElementById('salesTable').innerHTML = `
<tr><td colspan="7">
<div class="empty-state">
<div class="empty-icon">📭</div>
<div>판매 내역이 없습니다</div>
</div>
</td></tr>
`;
return;
}
const rows = data.sales.map((sale, idx) => {
const payBadge = getPayBadge(sale.pay_method);
const customerClass = sale.customer === '[비고객]' ? 'customer non-member' : 'customer';
const qrIcon = sale.qr_issued
? '<span class="status-icon qr-yes"></span>'
: '<span class="status-icon qr-no"></span>';
let claimedHtml = '-';
if (sale.claimed_name) {
claimedHtml = `
<div class="claimed-info">${sale.claimed_name}</div>
<div class="claimed-phone">${formatPhone(sale.claimed_phone)}</div>
`;
}
return `
<tr onclick="showDetail('${sale.order_no}', ${idx})" data-idx="${idx}">
<td><span class="time">${sale.time}</span></td>
<td class="right"><span class="amount">₩${Math.floor(sale.amount).toLocaleString()}</span></td>
<td><span class="${customerClass}">${sale.customer}</span></td>
<td class="center">${payBadge}</td>
<td class="center">${sale.item_count}</td>
<td class="center">${qrIcon}</td>
<td>${claimedHtml}</td>
</tr>
`;
}).join('');
document.getElementById('salesTable').innerHTML = rows;
}
function getPayBadge(method) {
const badges = {
'카드': '<span class="pay-badge pay-card">카드</span>',
'현금': '<span class="pay-badge pay-cash">현금</span>',
'현영': '<span class="pay-badge pay-receipt">현영</span>',
'카드+현금': '<span class="pay-badge pay-mixed">복합</span>',
};
return badges[method] || '<span class="pay-badge pay-none">-</span>';
}
function formatPhone(phone) {
if (!phone) return '';
const p = phone.replace(/\D/g, '');
if (p.length === 11) {
return `${p.slice(0,3)}-${p.slice(3,7)}-${p.slice(7)}`;
}
return phone;
}
async function showDetail(orderNo, idx) {
// 선택 표시
document.querySelectorAll('#salesTable tr').forEach(tr => tr.classList.remove('selected'));
document.querySelector(`#salesTable tr[data-idx="${idx}"]`)?.classList.add('selected');
const sale = salesData[idx];
// 패널 열기
document.getElementById('overlay').classList.add('visible');
document.getElementById('detailPanel').classList.add('open');
// 기본 정보 표시
document.getElementById('detailContent').innerHTML = `
<div class="detail-info">
<div class="detail-info-item">
<div class="detail-info-label">거래번호</div>
<div class="detail-info-value" style="font-size:13px; font-family:'JetBrains Mono'">${orderNo}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">시간</div>
<div class="detail-info-value">${sale.time}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">고객</div>
<div class="detail-info-value">${sale.customer}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">결제수단</div>
<div class="detail-info-value">${sale.pay_method || '-'}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">판매금액</div>
<div class="detail-info-value">₩${Math.floor(sale.amount).toLocaleString()}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">할인</div>
<div class="detail-info-value">₩${Math.floor(sale.discount).toLocaleString()}</div>
</div>
</div>
<div class="detail-items-title">📦 품목 목록</div>
<div id="itemsList">
<div class="loading"><div class="spinner"></div>품목 로딩 중...</div>
</div>
`;
// 품목 상세 로드
try {
const res = await fetch(`/api/admin/pos-live/detail/${orderNo}`);
const data = await res.json();
if (data.success && data.items.length > 0) {
const itemsHtml = data.items.map(item => `
<div class="detail-item">
<span class="detail-item-name">${item.product_name}</span>
<span class="detail-item-qty">×${item.quantity}</span>
<span class="detail-item-price">₩${Math.floor(item.total_price).toLocaleString()}</span>
</div>
`).join('');
document.getElementById('itemsList').innerHTML = itemsHtml;
} else {
document.getElementById('itemsList').innerHTML = '<div class="empty-state">품목 정보 없음</div>';
}
} catch (err) {
document.getElementById('itemsList').innerHTML = `<div class="empty-state">오류: ${err.message}</div>`;
}
}
function closeDetail() {
document.getElementById('overlay').classList.remove('visible');
document.getElementById('detailPanel').classList.remove('open');
document.querySelectorAll('#salesTable tr').forEach(tr => tr.classList.remove('selected'));
}
// ESC 키로 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDetail();
});
</script>
</body>
</html>