diff --git a/backend/app.py b/backend/app.py
index ff9389e..d239d09 100644
--- a/backend/app.py
+++ b/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)
diff --git a/backend/templates/admin.html b/backend/templates/admin.html
index ebeffb2..e7b9582 100644
--- a/backend/templates/admin.html
+++ b/backend/templates/admin.html
@@ -399,8 +399,9 @@
diff --git a/backend/templates/admin_sales_detail.html b/backend/templates/admin_sales_detail.html
new file mode 100644
index 0000000..87de8a2
--- /dev/null
+++ b/backend/templates/admin_sales_detail.html
@@ -0,0 +1,479 @@
+
+
+
+
+
+ 판매 상세 조회 - 청춘약국
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 판매일시 |
+ 상품명 |
+
+ 수량 |
+ 단가 |
+ 합계 |
+
+
+
+ | 로딩 중... |
+
+
+
+
+
+
+
+
diff --git a/backend/templates/admin_sales_pos.html b/backend/templates/admin_sales_pos.html
new file mode 100644
index 0000000..9c5fcdd
--- /dev/null
+++ b/backend/templates/admin_sales_pos.html
@@ -0,0 +1,902 @@
+
+
+
+
+
+ 판매 내역 - 청춘약국 POS
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 판매일 |
+ 상품명 |
+
+ 수량 |
+ 단가 |
+ 합계 |
+
+
+
+
+
+
+
+
+
+
+