pharmacy-pos-qr-system/backend/templates/admin_sales_pos.html
thug0bin ccb0067a1c feat: POS 스타일 판매내역 페이지 + 바코드/표준코드 조회
- /admin/sales: 다크 테마 POS 스타일 판매내역 (날짜별 그룹, 아코디언)
- /admin/sales-detail: 기존 라이트 테마 상세 조회 페이지
- 상품코드/바코드/표준코드 전환 버튼
- 바코드 시각화 + 매핑률 통계
- 대시보드 메뉴에 판매내역 링크 추가
2026-02-27 12:14:50 +09:00

903 lines
32 KiB
HTML

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