feat: POS 스타일 판매내역 페이지 + 바코드/표준코드 조회
- /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언) - /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지 - 상품코드/바코드/표준코드 전환 버튼 - 바코드 시각화 + 매핑률 통계 - 대시보드 메뉴에 판매내역 링크 추가
This commit is contained in:
parent
da51f4bfd1
commit
ccb0067a1c
294
backend/app.py
294
backend/app.py
@ -2445,6 +2445,300 @@ def api_kiosk_claim():
|
||||
return jsonify({'success': False, 'message': f'오류: {str(e)}'}), 500
|
||||
|
||||
|
||||
# ===== AI Gateway 모니터 페이지 =====
|
||||
|
||||
@app.route('/admin/ai-gw')
|
||||
def admin_ai_gw():
|
||||
"""AI Gateway 모니터 페이지"""
|
||||
return render_template('admin_ai_gw.html')
|
||||
|
||||
|
||||
# ===== 판매 상세 조회 페이지 =====
|
||||
|
||||
@app.route('/admin/sales-detail')
|
||||
def admin_sales_detail():
|
||||
"""판매 상세 조회 페이지 (상품코드/바코드/표준코드 매핑)"""
|
||||
return render_template('admin_sales_detail.html')
|
||||
|
||||
|
||||
@app.route('/admin/sales')
|
||||
def admin_sales_pos():
|
||||
"""판매 내역 페이지 (POS 스타일, 거래별 그룹핑)"""
|
||||
return render_template('admin_sales_pos.html')
|
||||
|
||||
|
||||
@app.route('/api/sales-detail')
|
||||
def api_sales_detail():
|
||||
"""
|
||||
판매 상세 조회 API (바코드 포함)
|
||||
GET /api/sales-detail?days=7&search=타이레놀&barcode=has&customer=홍길동
|
||||
"""
|
||||
try:
|
||||
days = int(request.args.get('days', 7))
|
||||
search = request.args.get('search', '').strip()
|
||||
barcode_filter = request.args.get('barcode', 'all') # all, has, none
|
||||
|
||||
mssql_session = db_manager.get_session('PM_PRES')
|
||||
drug_session = db_manager.get_session('PM_DRUG')
|
||||
|
||||
# 판매 내역 조회 (최근 N일)
|
||||
sales_query = text("""
|
||||
SELECT
|
||||
S.SL_DT_appl as sale_date,
|
||||
S.SL_NO_order as item_order,
|
||||
S.DrugCode as drug_code,
|
||||
ISNULL(G.GoodsName, '알 수 없음') as product_name,
|
||||
ISNULL(G.BARCODE, '') as barcode,
|
||||
ISNULL(G.SplName, '') as supplier,
|
||||
ISNULL(S.QUAN, 1) as quantity,
|
||||
ISNULL(S.SL_TOTAL_PRICE, 0) as total_price_db,
|
||||
ISNULL(G.Saleprice, 0) as unit_price
|
||||
FROM SALE_SUB S
|
||||
LEFT JOIN PM_DRUG.dbo.CD_GOODS G ON S.DrugCode = G.DrugCode
|
||||
WHERE S.SL_DT_appl >= CONVERT(VARCHAR(8), DATEADD(DAY, -:days, GETDATE()), 112)
|
||||
ORDER BY S.SL_DT_appl DESC, S.SL_NO_order DESC
|
||||
""")
|
||||
|
||||
rows = mssql_session.execute(sales_query, {'days': days}).fetchall()
|
||||
|
||||
items = []
|
||||
total_amount = 0
|
||||
barcode_count = 0
|
||||
unique_products = set()
|
||||
|
||||
for row in rows:
|
||||
drug_code = row.drug_code or ''
|
||||
product_name = row.product_name or ''
|
||||
barcode = row.barcode or ''
|
||||
|
||||
# 검색 필터 (상품명, 코드, 바코드)
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
if (search_lower not in product_name.lower() and
|
||||
search_lower not in drug_code.lower() and
|
||||
search_lower not in barcode.lower()):
|
||||
continue
|
||||
|
||||
# 바코드 필터
|
||||
if barcode_filter == 'has' and not barcode:
|
||||
continue
|
||||
if barcode_filter == 'none' and barcode:
|
||||
continue
|
||||
|
||||
# 표준코드 조회 (CD_BARCODE 테이블)
|
||||
standard_code = ''
|
||||
if barcode:
|
||||
try:
|
||||
std_result = drug_session.execute(text("""
|
||||
SELECT BASECODE FROM CD_BARCODE WHERE BARCODE = :barcode
|
||||
"""), {'barcode': barcode}).fetchone()
|
||||
if std_result and std_result[0]:
|
||||
standard_code = std_result[0]
|
||||
except:
|
||||
pass
|
||||
|
||||
quantity = int(row.quantity or 1)
|
||||
unit_price = float(row.unit_price or 0)
|
||||
total_price_from_db = float(row.total_price_db or 0)
|
||||
# DB에 합계가 있으면 사용, 없으면 계산
|
||||
total_price = total_price_from_db if total_price_from_db > 0 else (quantity * unit_price)
|
||||
|
||||
# 날짜 포맷팅
|
||||
sale_date_str = str(row.sale_date or '')
|
||||
if len(sale_date_str) == 8:
|
||||
sale_date_str = f"{sale_date_str[:4]}-{sale_date_str[4:6]}-{sale_date_str[6:]}"
|
||||
|
||||
items.append({
|
||||
'sale_date': sale_date_str,
|
||||
'drug_code': drug_code,
|
||||
'product_name': product_name,
|
||||
'barcode': barcode,
|
||||
'standard_code': standard_code,
|
||||
'supplier': row.supplier or '',
|
||||
'quantity': quantity,
|
||||
'unit_price': int(unit_price),
|
||||
'total_price': int(total_price)
|
||||
})
|
||||
|
||||
total_amount += total_price
|
||||
if barcode:
|
||||
barcode_count += 1
|
||||
unique_products.add(drug_code)
|
||||
|
||||
# 바코드 매핑률 계산
|
||||
barcode_rate = round(barcode_count / len(items) * 100, 1) if items else 0
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'items': items[:500], # 최대 500건
|
||||
'stats': {
|
||||
'total_count': len(items),
|
||||
'total_amount': int(total_amount),
|
||||
'barcode_rate': barcode_rate,
|
||||
'unique_products': len(unique_products)
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"판매 상세 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': str(e)
|
||||
}), 500
|
||||
|
||||
|
||||
# ===== Claude 상태 API =====
|
||||
|
||||
@app.route('/api/claude-status')
|
||||
def api_claude_status():
|
||||
"""
|
||||
Claude 사용량 상태 조회 (토큰 차감 없음)
|
||||
GET /api/claude-status
|
||||
GET /api/claude-status?detail=true — 전체 세션 상세 포함
|
||||
|
||||
Returns:
|
||||
{
|
||||
"ok": true,
|
||||
"connected": true,
|
||||
"model": "claude-opus-4-5",
|
||||
"mainSession": { ... },
|
||||
"summary": { ... },
|
||||
"sessions": [ ... ], // detail=true 일 때만
|
||||
"timestamp": "2026-02-27T09:45:00+09:00"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
from services.clawdbot_client import get_claude_status
|
||||
|
||||
# 상세 모드 여부
|
||||
detail_mode = request.args.get('detail', 'false').lower() == 'true'
|
||||
|
||||
status = get_claude_status()
|
||||
|
||||
if not status or not status.get('connected'):
|
||||
return jsonify({
|
||||
'ok': False,
|
||||
'connected': False,
|
||||
'error': status.get('error', 'Gateway 연결 실패'),
|
||||
'timestamp': datetime.now(KST).isoformat()
|
||||
}), 503
|
||||
|
||||
sessions_data = status.get('sessions', {})
|
||||
sessions_list = sessions_data.get('sessions', [])
|
||||
defaults = sessions_data.get('defaults', {})
|
||||
|
||||
# 메인 세션 찾기
|
||||
main_session = None
|
||||
for s in sessions_list:
|
||||
if s.get('key') == 'agent:main:main':
|
||||
main_session = s
|
||||
break
|
||||
|
||||
# 전체 토큰 합계
|
||||
total_tokens = sum(s.get('totalTokens', 0) for s in sessions_list)
|
||||
|
||||
# 메인 세션 컨텍스트 사용률
|
||||
context_used = 0
|
||||
context_max = defaults.get('contextTokens', 200000)
|
||||
context_percent = 0
|
||||
if main_session:
|
||||
context_used = main_session.get('totalTokens', 0)
|
||||
context_max = main_session.get('contextTokens', context_max)
|
||||
if context_max > 0:
|
||||
context_percent = round(context_used / context_max * 100, 1)
|
||||
|
||||
# 기본 응답
|
||||
response = {
|
||||
'ok': True,
|
||||
'connected': True,
|
||||
'model': f"{defaults.get('modelProvider', 'unknown')}/{defaults.get('model', 'unknown')}",
|
||||
'context': {
|
||||
'used': context_used,
|
||||
'max': context_max,
|
||||
'percent': context_percent,
|
||||
'display': f"{context_used//1000}k/{context_max//1000}k ({context_percent}%)"
|
||||
},
|
||||
'mainSession': {
|
||||
'key': main_session.get('key') if main_session else None,
|
||||
'inputTokens': main_session.get('inputTokens', 0) if main_session else 0,
|
||||
'outputTokens': main_session.get('outputTokens', 0) if main_session else 0,
|
||||
'totalTokens': main_session.get('totalTokens', 0) if main_session else 0,
|
||||
'lastChannel': main_session.get('lastChannel') if main_session else None
|
||||
} if main_session else None,
|
||||
'summary': {
|
||||
'totalSessions': len(sessions_list),
|
||||
'totalTokens': total_tokens,
|
||||
'activeModel': defaults.get('model')
|
||||
},
|
||||
'timestamp': datetime.now(KST).isoformat()
|
||||
}
|
||||
|
||||
# 상세 모드: 전체 세션 목록 추가
|
||||
if detail_mode:
|
||||
detailed_sessions = []
|
||||
for s in sessions_list:
|
||||
session_tokens = s.get('totalTokens', 0)
|
||||
session_context_max = s.get('contextTokens', 200000)
|
||||
session_percent = round(session_tokens / session_context_max * 100, 1) if session_context_max > 0 else 0
|
||||
|
||||
# 세션 키에서 이름 추출 (agent:main:xxx → xxx)
|
||||
session_key = s.get('key', '')
|
||||
session_name = session_key.split(':')[-1] if ':' in session_key else session_key
|
||||
|
||||
# 마지막 활동 시간
|
||||
updated_at = s.get('updatedAt')
|
||||
updated_str = None
|
||||
if updated_at:
|
||||
try:
|
||||
dt = datetime.fromtimestamp(updated_at / 1000, tz=KST)
|
||||
updated_str = dt.strftime('%Y-%m-%d %H:%M')
|
||||
except:
|
||||
pass
|
||||
|
||||
detailed_sessions.append({
|
||||
'key': session_key,
|
||||
'name': session_name,
|
||||
'displayName': s.get('displayName', session_name),
|
||||
'model': f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}",
|
||||
'tokens': {
|
||||
'input': s.get('inputTokens', 0),
|
||||
'output': s.get('outputTokens', 0),
|
||||
'total': session_tokens,
|
||||
'contextMax': session_context_max,
|
||||
'contextPercent': session_percent,
|
||||
'display': f"{session_tokens//1000}k/{session_context_max//1000}k ({session_percent}%)"
|
||||
},
|
||||
'channel': s.get('lastChannel') or s.get('origin', {}).get('provider'),
|
||||
'kind': s.get('kind'),
|
||||
'updatedAt': updated_str
|
||||
})
|
||||
|
||||
# 토큰 사용량 순으로 정렬
|
||||
detailed_sessions.sort(key=lambda x: x['tokens']['total'], reverse=True)
|
||||
response['sessions'] = detailed_sessions
|
||||
|
||||
# 모델별 통계
|
||||
model_stats = {}
|
||||
for s in sessions_list:
|
||||
model_key = f"{s.get('modelProvider', 'unknown')}/{s.get('model', 'unknown')}"
|
||||
if model_key not in model_stats:
|
||||
model_stats[model_key] = {'sessions': 0, 'tokens': 0}
|
||||
model_stats[model_key]['sessions'] += 1
|
||||
model_stats[model_key]['tokens'] += s.get('totalTokens', 0)
|
||||
response['modelStats'] = model_stats
|
||||
|
||||
return jsonify(response)
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Claude 상태 조회 오류: {e}")
|
||||
return jsonify({
|
||||
'ok': False,
|
||||
'connected': False,
|
||||
'error': str(e),
|
||||
'timestamp': datetime.now(KST).isoformat()
|
||||
}), 500
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
# 개발 모드로 실행
|
||||
app.run(host='0.0.0.0', port=7001, debug=True)
|
||||
|
||||
@ -399,8 +399,9 @@
|
||||
<div class="header-subtitle">청춘약국 마일리지 관리</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<a href="/admin/sales" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🧾 판매내역</a>
|
||||
<a href="/admin/ai-crm" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡 로그</a>
|
||||
<a href="/admin/alimtalk" style="color:rgba(255,255,255,0.9);text-decoration:none;font-size:14px;padding:8px 16px;border-radius:8px;background:rgba(255,255,255,0.15);transition:all 0.2s;">📨 알림톡</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
479
backend/templates/admin_sales_detail.html
Normal file
479
backend/templates/admin_sales_detail.html
Normal file
@ -0,0 +1,479 @@
|
||||
<!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, #0f766e 0%, #0d9488 50%, #14b8a6 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: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 60px;
|
||||
}
|
||||
|
||||
/* ── 검색/필터 영역 ── */
|
||||
.search-section {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
.search-group input, .search-group select {
|
||||
padding: 10px 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
min-width: 150px;
|
||||
}
|
||||
.search-group input:focus, .search-group select:focus {
|
||||
outline: none;
|
||||
border-color: #0d9488;
|
||||
box-shadow: 0 0 0 3px rgba(13, 148, 136, 0.1);
|
||||
}
|
||||
.search-btn {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
.search-btn:hover { background: #0f766e; }
|
||||
|
||||
/* ── 통계 카드 ── */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 14px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 18px 20px;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.stat-value.teal { color: #0d9488; }
|
||||
.stat-value.blue { color: #3b82f6; }
|
||||
.stat-value.purple { color: #8b5cf6; }
|
||||
.stat-value.orange { color: #f59e0b; }
|
||||
|
||||
/* ── 테이블 ── */
|
||||
.table-wrap {
|
||||
background: #fff;
|
||||
border-radius: 14px;
|
||||
border: 1px solid #e2e8f0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.table-header {
|
||||
padding: 16px 20px;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.table-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.table-count {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
thead th {
|
||||
background: #f8fafc;
|
||||
padding: 12px 14px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
tbody td {
|
||||
padding: 14px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
}
|
||||
tbody tr:hover { background: #f8fafc; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* ── 코드 스타일 ── */
|
||||
.code {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
.code-barcode {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
.code-standard {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
.code-na {
|
||||
background: #f1f5f9;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 제품명 ── */
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
}
|
||||
.product-category {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── 금액 ── */
|
||||
.price {
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
.qty {
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── 코드 전환 버튼 ── */
|
||||
.code-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.code-toggle button {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: #fff;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.code-toggle button.active {
|
||||
background: #0d9488;
|
||||
color: #fff;
|
||||
border-color: #0d9488;
|
||||
}
|
||||
.code-toggle button:hover:not(.active) {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
/* ── 빈 상태 ── */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 60px 20px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
/* ── 로딩 ── */
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* ── 반응형 ── */
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.search-section { flex-direction: column; }
|
||||
.search-group { width: 100%; }
|
||||
.search-group input, .search-group select { width: 100%; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { min-width: 900px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-nav">
|
||||
<a href="/admin">← 관리자 홈</a>
|
||||
<div>
|
||||
<a href="/admin/sales" style="margin-right: 16px;">판매 내역</a>
|
||||
<a href="/admin/ai-crm" style="margin-right: 16px;">AI 업셀링</a>
|
||||
<a href="/admin/ai-gw">Gateway 모니터</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>판매 상세 조회</h1>
|
||||
<p>상품코드 · 바코드 · 표준코드 매핑 조회</p>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색/필터 -->
|
||||
<div class="search-section">
|
||||
<div class="search-group">
|
||||
<label>조회 기간</label>
|
||||
<select id="periodSelect">
|
||||
<option value="1">오늘</option>
|
||||
<option value="7" selected>최근 7일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>검색 (상품명/코드)</label>
|
||||
<input type="text" id="searchInput" placeholder="타이레놀, LB000...">
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>바코드 필터</label>
|
||||
<select id="barcodeFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="has">바코드 있음</option>
|
||||
<option value="none">바코드 없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadSalesData()">조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 판매 건수</div>
|
||||
<div class="stat-value teal" id="statTotal">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">총 매출액</div>
|
||||
<div class="stat-value blue" id="statAmount">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">바코드 매핑률</div>
|
||||
<div class="stat-value purple" id="statBarcode">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">고유 상품 수</div>
|
||||
<div class="stat-value orange" id="statProducts">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 코드 표시 토글 -->
|
||||
<div class="code-toggle">
|
||||
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
|
||||
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
|
||||
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
|
||||
<button data-code="all" onclick="setCodeView('all')">전체 표시</button>
|
||||
</div>
|
||||
|
||||
<!-- 테이블 -->
|
||||
<div class="table-wrap">
|
||||
<div class="table-header">
|
||||
<div class="table-title">판매 내역</div>
|
||||
<div class="table-count" id="tableCount">-</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매일시</th>
|
||||
<th>상품명</th>
|
||||
<th id="codeHeader">상품코드</th>
|
||||
<th>수량</th>
|
||||
<th>단가</th>
|
||||
<th>합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="salesTableBody">
|
||||
<tr><td colspan="6" class="loading">로딩 중...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let salesData = [];
|
||||
let currentCodeView = 'drug';
|
||||
|
||||
function setCodeView(view) {
|
||||
currentCodeView = view;
|
||||
document.querySelectorAll('.code-toggle button').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.code === view);
|
||||
});
|
||||
|
||||
const header = document.getElementById('codeHeader');
|
||||
if (view === 'drug') header.textContent = '상품코드';
|
||||
else if (view === 'barcode') header.textContent = '바코드';
|
||||
else if (view === 'standard') header.textContent = '표준코드';
|
||||
else header.textContent = '코드 (상품/바코드/표준)';
|
||||
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function formatPrice(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num) + '원';
|
||||
}
|
||||
|
||||
function renderCodeCell(item) {
|
||||
if (currentCodeView === 'drug') {
|
||||
return `<span class="code code-drug">${item.drug_code}</span>`;
|
||||
} else if (currentCodeView === 'barcode') {
|
||||
return item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code code-na">N/A</span>`;
|
||||
} else if (currentCodeView === 'standard') {
|
||||
return item.standard_code
|
||||
? `<span class="code code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code code-na">N/A</span>`;
|
||||
} else {
|
||||
// 전체 표시
|
||||
let html = `<span class="code code-drug">${item.drug_code}</span><br>`;
|
||||
html += item.barcode
|
||||
? `<span class="code code-barcode">${item.barcode}</span><br>`
|
||||
: `<span class="code code-na">바코드 없음</span><br>`;
|
||||
html += item.standard_code
|
||||
? `<span class="code code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code code-na">표준코드 없음</span>`;
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const tbody = document.getElementById('salesTableBody');
|
||||
|
||||
if (salesData.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = salesData.map(item => `
|
||||
<tr>
|
||||
<td style="white-space:nowrap;font-size:12px;color:#64748b;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-name">${escapeHtml(item.product_name)}</div>
|
||||
${item.supplier ? `<div class="product-category">${escapeHtml(item.supplier)}</div>` : ''}
|
||||
</td>
|
||||
<td>${renderCodeCell(item)}</td>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}</td>
|
||||
<td class="price">${formatPrice(item.total_price)}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
'<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
|
||||
|
||||
fetch(`/api/sales-detail?days=${period}&search=${encodeURIComponent(search)}&barcode=${barcodeFilter}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
salesData = data.items;
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('statTotal').textContent = data.stats.total_count.toLocaleString();
|
||||
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
|
||||
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
|
||||
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
|
||||
document.getElementById('tableCount').textContent = `${salesData.length}건`;
|
||||
|
||||
renderTable();
|
||||
} else {
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
`<tr><td colspan="6" class="empty-state">오류: ${data.error}</td></tr>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('salesTableBody').innerHTML =
|
||||
`<tr><td colspan="6" class="empty-state">데이터 로드 실패</td></tr>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
902
backend/templates/admin_sales_pos.html
Normal file
902
backend/templates/admin_sales_pos.html
Normal file
@ -0,0 +1,902 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>판매 내역 - 청춘약국 POS</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;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--bg-card: #1e293b;
|
||||
--bg-card-hover: #334155;
|
||||
--border: #334155;
|
||||
--text-primary: #f1f5f9;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--accent-teal: #14b8a6;
|
||||
--accent-blue: #3b82f6;
|
||||
--accent-purple: #a855f7;
|
||||
--accent-amber: #f59e0b;
|
||||
--accent-emerald: #10b981;
|
||||
--accent-rose: #f43f5e;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ══════════════════ 헤더 ══════════════════ */
|
||||
.header {
|
||||
background: linear-gradient(135deg, #0f766e 0%, #0d9488 50%, #14b8a6 100%);
|
||||
padding: 20px 24px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left h1 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.5px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.header-left p {
|
||||
font-size: 13px;
|
||||
opacity: 0.85;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.header-nav a {
|
||||
color: rgba(255,255,255,0.85);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255,255,255,0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.header-nav a:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* ══════════════════ 컨텐츠 ══════════════════ */
|
||||
.content {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 검색 영역 ══════════════════ */
|
||||
.search-bar {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
padding: 20px 24px;
|
||||
margin-bottom: 20px;
|
||||
border: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
}
|
||||
.search-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.search-group label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.search-group input, .search-group select {
|
||||
padding: 10px 14px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
color: var(--text-primary);
|
||||
min-width: 140px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-group input:focus, .search-group select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-teal);
|
||||
box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.2);
|
||||
}
|
||||
.search-group input::placeholder { color: var(--text-muted); }
|
||||
.search-btn {
|
||||
background: linear-gradient(135deg, var(--accent-teal), var(--accent-emerald));
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 10px 28px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.search-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(20, 184, 166, 0.4);
|
||||
}
|
||||
|
||||
/* ══════════════════ 통계 카드 ══════════════════ */
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 14px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--border);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 3px;
|
||||
}
|
||||
.stat-card.teal::before { background: var(--accent-teal); }
|
||||
.stat-card.blue::before { background: var(--accent-blue); }
|
||||
.stat-card.purple::before { background: var(--accent-purple); }
|
||||
.stat-card.amber::before { background: var(--accent-amber); }
|
||||
.stat-card.emerald::before { background: var(--accent-emerald); }
|
||||
|
||||
.stat-icon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -1px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat-card.teal .stat-value { color: var(--accent-teal); }
|
||||
.stat-card.blue .stat-value { color: var(--accent-blue); }
|
||||
.stat-card.purple .stat-value { color: var(--accent-purple); }
|
||||
.stat-card.amber .stat-value { color: var(--accent-amber); }
|
||||
.stat-card.emerald .stat-value { color: var(--accent-emerald); }
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 뷰 토글 ══════════════════ */
|
||||
.view-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.code-toggle {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
background: var(--bg-secondary);
|
||||
padding: 4px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.code-toggle button {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.code-toggle button.active {
|
||||
background: var(--accent-teal);
|
||||
color: #fff;
|
||||
}
|
||||
.code-toggle button:hover:not(.active) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.view-mode {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.view-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.view-btn.active {
|
||||
border-color: var(--accent-teal);
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
|
||||
/* ══════════════════ 거래 카드 (그룹별) ══════════════════ */
|
||||
.transactions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-card {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.tx-card:hover {
|
||||
border-color: var(--accent-teal);
|
||||
}
|
||||
.tx-header {
|
||||
padding: 16px 20px;
|
||||
background: var(--bg-secondary);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.tx-header:hover {
|
||||
background: var(--bg-card-hover);
|
||||
}
|
||||
.tx-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.tx-id {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--accent-teal);
|
||||
}
|
||||
.tx-time {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.tx-customer {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary);
|
||||
padding: 4px 12px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.tx-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.tx-count {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.tx-amount {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--accent-emerald);
|
||||
}
|
||||
.tx-toggle {
|
||||
font-size: 16px;
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.tx-card.open .tx-toggle {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* 품목 테이블 */
|
||||
.tx-items {
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.3s ease-out;
|
||||
}
|
||||
.tx-card.open .tx-items {
|
||||
max-height: 2000px;
|
||||
}
|
||||
.items-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.items-table th {
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.items-table th:nth-child(4),
|
||||
.items-table th:nth-child(5),
|
||||
.items-table th:nth-child(6) {
|
||||
text-align: right;
|
||||
}
|
||||
.items-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.items-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
.items-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* 제품 셀 */
|
||||
.product-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.product-name {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.product-supplier {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* 코드 뱃지 */
|
||||
.code-badge {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
display: inline-block;
|
||||
}
|
||||
.code-drug {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
.code-barcode {
|
||||
background: rgba(16, 185, 129, 0.2);
|
||||
color: #34d399;
|
||||
border: 1px solid rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
.code-standard {
|
||||
background: rgba(245, 158, 11, 0.2);
|
||||
color: #fbbf24;
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
.code-na {
|
||||
background: rgba(148, 163, 184, 0.1);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid rgba(148, 163, 184, 0.2);
|
||||
}
|
||||
.code-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 바코드 시각화 */
|
||||
.barcode-visual {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.barcode-bars {
|
||||
display: flex;
|
||||
gap: 1px;
|
||||
align-items: flex-end;
|
||||
height: 20px;
|
||||
}
|
||||
.barcode-bars span {
|
||||
width: 2px;
|
||||
background: var(--accent-emerald);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* 숫자 정렬 */
|
||||
.items-table td.qty,
|
||||
.items-table td.price {
|
||||
text-align: right;
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-weight: 500;
|
||||
}
|
||||
.items-table td.price.total {
|
||||
color: var(--accent-teal);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ══════════════════ 리스트 뷰 ══════════════════ */
|
||||
.list-view {
|
||||
display: none;
|
||||
}
|
||||
.list-view.active {
|
||||
display: block;
|
||||
}
|
||||
.list-table-wrap {
|
||||
background: var(--bg-card);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
.list-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.list-table th {
|
||||
padding: 14px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
text-align: left;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
.list-table td {
|
||||
padding: 14px 16px;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||||
}
|
||||
.list-table tr:hover {
|
||||
background: rgba(255,255,255,0.02);
|
||||
}
|
||||
|
||||
/* ══════════════════ 로딩/빈 상태 ══════════════════ */
|
||||
.loading-state, .empty-state {
|
||||
text-align: center;
|
||||
padding: 80px 20px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--border);
|
||||
border-top-color: var(--accent-teal);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
/* ══════════════════ 반응형 ══════════════════ */
|
||||
@media (max-width: 1200px) {
|
||||
.stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.header-nav { display: none; }
|
||||
.search-bar { flex-direction: column; }
|
||||
.search-group { width: 100%; }
|
||||
.search-group input, .search-group select { width: 100%; }
|
||||
.tx-info { flex-wrap: wrap; gap: 8px; }
|
||||
.view-controls { flex-direction: column; gap: 12px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-left">
|
||||
<h1>🧾 판매 내역</h1>
|
||||
<p>POS 판매 데이터 · 바코드 · 표준코드 조회</p>
|
||||
</div>
|
||||
<nav class="header-nav">
|
||||
<a href="/admin">📊 대시보드</a>
|
||||
<a href="/admin/ai-crm">🤖 AI CRM</a>
|
||||
<a href="/admin/alimtalk">📨 알림톡</a>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<!-- 검색 -->
|
||||
<div class="search-bar">
|
||||
<div class="search-group">
|
||||
<label>조회 기간</label>
|
||||
<select id="periodSelect">
|
||||
<option value="1">오늘</option>
|
||||
<option value="3" selected>최근 3일</option>
|
||||
<option value="7">최근 7일</option>
|
||||
<option value="30">최근 30일</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>검색어</label>
|
||||
<input type="text" id="searchInput" placeholder="상품명, 코드, 바코드...">
|
||||
</div>
|
||||
<div class="search-group">
|
||||
<label>바코드</label>
|
||||
<select id="barcodeFilter">
|
||||
<option value="all">전체</option>
|
||||
<option value="has">있음</option>
|
||||
<option value="none">없음</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="search-btn" onclick="loadSalesData()">🔍 조회</button>
|
||||
</div>
|
||||
|
||||
<!-- 통계 -->
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card teal">
|
||||
<div class="stat-icon">📅</div>
|
||||
<div class="stat-value" id="statTxCount">-</div>
|
||||
<div class="stat-label">조회 일수</div>
|
||||
</div>
|
||||
<div class="stat-card blue">
|
||||
<div class="stat-icon">📦</div>
|
||||
<div class="stat-value" id="statItemCount">-</div>
|
||||
<div class="stat-label">총 판매 품목</div>
|
||||
</div>
|
||||
<div class="stat-card emerald">
|
||||
<div class="stat-icon">💰</div>
|
||||
<div class="stat-value" id="statAmount">-</div>
|
||||
<div class="stat-label">총 매출액</div>
|
||||
</div>
|
||||
<div class="stat-card purple">
|
||||
<div class="stat-icon">📊</div>
|
||||
<div class="stat-value" id="statBarcode">-</div>
|
||||
<div class="stat-label">바코드 매핑률</div>
|
||||
</div>
|
||||
<div class="stat-card amber">
|
||||
<div class="stat-icon">🏷️</div>
|
||||
<div class="stat-value" id="statProducts">-</div>
|
||||
<div class="stat-label">고유 상품</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 뷰 컨트롤 -->
|
||||
<div class="view-controls">
|
||||
<div class="code-toggle">
|
||||
<button class="active" data-code="drug" onclick="setCodeView('drug')">상품코드</button>
|
||||
<button data-code="barcode" onclick="setCodeView('barcode')">바코드</button>
|
||||
<button data-code="standard" onclick="setCodeView('standard')">표준코드</button>
|
||||
<button data-code="all" onclick="setCodeView('all')">전체</button>
|
||||
</div>
|
||||
<div class="view-mode">
|
||||
<button class="view-btn active" data-view="group" onclick="setViewMode('group')">📁 거래별</button>
|
||||
<button class="view-btn" data-view="list" onclick="setViewMode('list')">📋 목록</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 거래별 뷰 -->
|
||||
<div id="groupView" class="transactions-container">
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 리스트 뷰 -->
|
||||
<div id="listView" class="list-view">
|
||||
<div class="list-table-wrap">
|
||||
<table class="list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>판매일</th>
|
||||
<th>상품명</th>
|
||||
<th id="listCodeHeader">상품코드</th>
|
||||
<th style="text-align:center">수량</th>
|
||||
<th style="text-align:right">단가</th>
|
||||
<th style="text-align:right">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="listTableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let rawData = []; // API에서 받은 원본 데이터
|
||||
let groupedData = []; // 거래별 그룹화된 데이터
|
||||
let currentCodeView = 'drug';
|
||||
let currentViewMode = 'group';
|
||||
|
||||
// ──────────────── 코드 뷰 전환 ────────────────
|
||||
function setCodeView(view) {
|
||||
currentCodeView = view;
|
||||
document.querySelectorAll('.code-toggle button').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.code === view);
|
||||
});
|
||||
|
||||
const headers = {
|
||||
'drug': '상품코드',
|
||||
'barcode': '바코드',
|
||||
'standard': '표준코드',
|
||||
'all': '코드 정보'
|
||||
};
|
||||
document.querySelectorAll('#codeHeader, #listCodeHeader').forEach(el => {
|
||||
if (el) el.textContent = headers[view];
|
||||
});
|
||||
|
||||
render();
|
||||
}
|
||||
|
||||
// ──────────────── 뷰 모드 전환 ────────────────
|
||||
function setViewMode(mode) {
|
||||
currentViewMode = mode;
|
||||
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||
btn.classList.toggle('active', btn.dataset.view === mode);
|
||||
});
|
||||
document.getElementById('groupView').style.display = mode === 'group' ? 'flex' : 'none';
|
||||
document.getElementById('listView').classList.toggle('active', mode === 'list');
|
||||
}
|
||||
|
||||
// ──────────────── 코드 렌더링 ────────────────
|
||||
function renderCode(item) {
|
||||
if (currentCodeView === 'drug') {
|
||||
return `<span class="code-badge code-drug">${item.drug_code}</span>`;
|
||||
} else if (currentCodeView === 'barcode') {
|
||||
if (item.barcode) {
|
||||
return `
|
||||
<div class="barcode-visual">
|
||||
<span class="code-badge code-barcode">${item.barcode}</span>
|
||||
${renderBarcodeBars(item.barcode)}
|
||||
</div>`;
|
||||
}
|
||||
return `<span class="code-badge code-na">—</span>`;
|
||||
} else if (currentCodeView === 'standard') {
|
||||
return item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: `<span class="code-badge code-na">—</span>`;
|
||||
} else {
|
||||
return `
|
||||
<div class="code-stack">
|
||||
<span class="code-badge code-drug">${item.drug_code}</span>
|
||||
${item.barcode
|
||||
? `<span class="code-badge code-barcode">${item.barcode}</span>`
|
||||
: `<span class="code-badge code-na">바코드 없음</span>`}
|
||||
${item.standard_code
|
||||
? `<span class="code-badge code-standard">${item.standard_code}</span>`
|
||||
: ''}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// 바코드 시각화 바
|
||||
function renderBarcodeBars(barcode) {
|
||||
const bars = barcode.split('').map(c => {
|
||||
const h = 8 + (parseInt(c) || c.charCodeAt(0) % 10) * 1.2;
|
||||
return `<span style="height:${h}px"></span>`;
|
||||
}).join('');
|
||||
return `<div class="barcode-bars">${bars}</div>`;
|
||||
}
|
||||
|
||||
// ──────────────── 포맷 ────────────────
|
||||
function formatPrice(num) {
|
||||
return new Intl.NumberFormat('ko-KR').format(num);
|
||||
}
|
||||
|
||||
function formatDateTime(dt) {
|
||||
if (!dt) return '-';
|
||||
const d = new Date(dt);
|
||||
return `${d.getMonth()+1}/${d.getDate()} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return '';
|
||||
return str.replace(/[&<>"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 그룹화 (날짜별) ────────────────
|
||||
function groupByDate(items) {
|
||||
const map = new Map();
|
||||
items.forEach(item => {
|
||||
const key = item.sale_date;
|
||||
if (!map.has(key)) {
|
||||
map.set(key, {
|
||||
date: item.sale_date,
|
||||
items: [],
|
||||
total: 0
|
||||
});
|
||||
}
|
||||
const group = map.get(key);
|
||||
group.items.push(item);
|
||||
group.total += item.total_price || 0;
|
||||
});
|
||||
return Array.from(map.values()).sort((a, b) =>
|
||||
b.date.localeCompare(a.date)
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────── 렌더링 ────────────────
|
||||
function render() {
|
||||
renderGroupView();
|
||||
renderListView();
|
||||
}
|
||||
|
||||
function renderGroupView() {
|
||||
const container = document.getElementById('groupView');
|
||||
|
||||
if (groupedData.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📭</div>
|
||||
<div>판매 내역이 없습니다</div>
|
||||
</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = groupedData.map((tx, idx) => `
|
||||
<div class="tx-card" id="tx-${idx}">
|
||||
<div class="tx-header" onclick="toggleTransaction(${idx})">
|
||||
<div class="tx-info">
|
||||
<span class="tx-id">📅 ${tx.date}</span>
|
||||
</div>
|
||||
<div class="tx-summary">
|
||||
<span class="tx-count">${tx.items.length}개 품목</span>
|
||||
<span class="tx-amount">${formatPrice(tx.total)}원</span>
|
||||
<span class="tx-toggle">▼</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tx-items">
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40%">상품명</th>
|
||||
<th id="codeHeader-${idx}">상품코드</th>
|
||||
<th style="text-align:right;width:8%">수량</th>
|
||||
<th style="text-align:right;width:12%">단가</th>
|
||||
<th style="text-align:right;width:12%">합계</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tx.items.map(item => `
|
||||
<tr>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td class="qty">${item.quantity}</td>
|
||||
<td class="price">${formatPrice(item.unit_price)}원</td>
|
||||
<td class="price total">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderListView() {
|
||||
const tbody = document.getElementById('listTableBody');
|
||||
|
||||
if (rawData.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="6" class="empty-state">판매 내역이 없습니다</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = rawData.map(item => `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);font-size:12px;">${item.sale_date}</td>
|
||||
<td>
|
||||
<div class="product-cell">
|
||||
<span class="product-name">${escapeHtml(item.product_name)}</span>
|
||||
${item.supplier ? `<span class="product-supplier">${escapeHtml(item.supplier)}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>${renderCode(item)}</td>
|
||||
<td style="text-align:center">${item.quantity}</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;">${formatPrice(item.unit_price)}원</td>
|
||||
<td style="text-align:right;font-family:'JetBrains Mono',monospace;color:var(--accent-teal);font-weight:600;">${formatPrice(item.total_price)}원</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function toggleTransaction(idx) {
|
||||
const card = document.getElementById(`tx-${idx}`);
|
||||
card.classList.toggle('open');
|
||||
}
|
||||
|
||||
// ──────────────── 데이터 로드 ────────────────
|
||||
function loadSalesData() {
|
||||
const period = document.getElementById('periodSelect').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const barcodeFilter = document.getElementById('barcodeFilter').value;
|
||||
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
<div>데이터 로딩 중...</div>
|
||||
</div>`;
|
||||
|
||||
let url = `/api/sales-detail?days=${period}&barcode=${barcodeFilter}`;
|
||||
if (search) url += `&search=${encodeURIComponent(search)}`;
|
||||
|
||||
fetch(url)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
rawData = data.items;
|
||||
groupedData = groupByDate(rawData);
|
||||
|
||||
// 통계 업데이트
|
||||
document.getElementById('statTxCount').textContent = groupedData.length.toLocaleString();
|
||||
document.getElementById('statItemCount').textContent = data.stats.total_count.toLocaleString();
|
||||
document.getElementById('statAmount').textContent = formatPrice(data.stats.total_amount);
|
||||
document.getElementById('statBarcode').textContent = data.stats.barcode_rate + '%';
|
||||
document.getElementById('statProducts').textContent = data.stats.unique_products.toLocaleString();
|
||||
|
||||
render();
|
||||
} else {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">⚠️</div>
|
||||
<div>오류: ${data.error}</div>
|
||||
</div>`;
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
document.getElementById('groupView').innerHTML = `
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">❌</div>
|
||||
<div>데이터 로드 실패</div>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
// 엔터키 검색
|
||||
document.getElementById('searchInput').addEventListener('keypress', e => {
|
||||
if (e.key === 'Enter') loadSalesData();
|
||||
});
|
||||
|
||||
// 초기 로드
|
||||
loadSalesData();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user