feat: POS 스타일 판매내역 페이지 + 바코드/표준코드 조회

- /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언)
- /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지
- 상품코드/바코드/표준코드 전환 버튼
- 바코드 시각화 + 매핑률 통계
- 대시보드 메뉴에 판매내역 링크 추가
This commit is contained in:
thug0bin 2026-02-27 12:14:50 +09:00
parent da51f4bfd1
commit ccb0067a1c
4 changed files with 1677 additions and 1 deletions

View File

@ -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)

View File

@ -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>

View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>

View 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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>