feat: POS 실시간 판매 조회 웹 페이지 (Qt GUI 웹 버전)
This commit is contained in:
parent
52a4f69abc
commit
e37659dc04
263
backend/app.py
263
backend/app.py
@ -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
|
||||
|
||||
|
||||
68
backend/scripts/perf_test.py
Normal file
68
backend/scripts/perf_test.py
Normal 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=== 측정 완료 ===')
|
||||
23
backend/scripts/test_pets_migration.py
Normal file
23
backend/scripts/test_pets_migration.py
Normal 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 테이블 없음')
|
||||
@ -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"
|
||||
|
||||
750
backend/templates/admin_pos_live.html
Normal file
750
backend/templates/admin_pos_live.html
Normal 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>
|
||||
Loading…
Reference in New Issue
Block a user