pharmacy-pos-qr-system/backend/templates/admin_sales_detail.html
thug0bin 9bd2174501 feat: 제품 검색 페이지 및 QR 라벨 인쇄 기능
- /admin/products: 전체 제품 검색 페이지 (OTC)
- /api/products: 제품 검색 API (세트상품 바코드 포함)
- qr_printer.py: Brother QL-710W 프린터 연동
- /api/qr-print, /api/qr-preview: QR 라벨 인쇄/미리보기 API
- 판매상세 페이지에 QR 인쇄 버튼 추가
- 수량 선택 UI (+/- 버튼, 최대 10장)
- 세트상품 제조사 표시 개선
- 대시보드 헤더에 제품검색/판매조회 탭 추가
2026-02-27 13:56:26 +09:00

779 lines
26 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!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;
}
/* ── QR 인쇄 버튼 ── */
.btn-qr {
background: #8b5cf6;
color: #fff;
border: none;
padding: 6px 12px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.btn-qr:hover { background: #7c3aed; }
.btn-qr:disabled {
background: #cbd5e1;
cursor: not-allowed;
}
.btn-qr.printing {
background: #f59e0b;
}
/* ── 모달 ── */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal-box {
background: #fff;
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 90%;
text-align: center;
}
.modal-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 16px;
}
.modal-preview {
margin: 16px 0;
}
.modal-preview img {
max-width: 200px;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
/* ── 수량 선택기 ── */
.qty-selector {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin: 20px 0;
}
.qty-btn {
width: 44px;
height: 44px;
border: none;
background: #f1f5f9;
font-size: 24px;
font-weight: 600;
color: #64748b;
cursor: pointer;
transition: all 0.15s;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.qty-btn:first-child {
border-radius: 12px 0 0 12px;
}
.qty-btn:last-child {
border-radius: 0 12px 12px 0;
}
.qty-btn:hover {
background: #e2e8f0;
color: #334155;
}
.qty-btn:active {
transform: scale(0.95);
background: #cbd5e1;
}
.qty-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.qty-value {
width: 64px;
height: 44px;
background: #fff;
border: 2px solid #e2e8f0;
border-left: none;
border-right: none;
font-size: 20px;
font-weight: 700;
color: #1e293b;
display: flex;
align-items: center;
justify-content: center;
}
.qty-label {
font-size: 13px;
color: #64748b;
margin-bottom: 8px;
}
.modal-btns {
display: flex;
gap: 12px;
justify-content: center;
margin-top: 20px;
}
.modal-btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.15s;
}
.modal-btn.cancel {
background: #f1f5f9;
color: #64748b;
}
.modal-btn.cancel:hover { background: #e2e8f0; }
.modal-btn.confirm {
background: #8b5cf6;
color: #fff;
}
.modal-btn.confirm:hover { background: #7c3aed; }
.modal-btn.confirm:active { transform: scale(0.98); }
/* ── 반응형 ── */
@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>
<th>QR</th>
</tr>
</thead>
<tbody id="salesTableBody">
<tr><td colspan="7" 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="7" class="empty-state">판매 내역이 없습니다</td></tr>';
return;
}
tbody.innerHTML = salesData.map((item, idx) => `
<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>
<td>
<button class="btn-qr" onclick="printQR(${idx})" title="QR 라벨 인쇄">
🏷️ QR
</button>
</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="7" 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="7" class="empty-state">오류: ${data.error}</td></tr>`;
}
})
.catch(err => {
document.getElementById('salesTableBody').innerHTML =
`<tr><td colspan="7" class="empty-state">데이터 로드 실패</td></tr>`;
});
}
// QR 인쇄 관련
let selectedItem = null;
let printQty = 1;
const MAX_QTY = 10;
const MIN_QTY = 1;
function adjustQty(delta) {
printQty = Math.max(MIN_QTY, Math.min(MAX_QTY, printQty + delta));
updateQtyUI();
}
function updateQtyUI() {
document.getElementById('qtyValue').textContent = printQty;
document.getElementById('qtyMinus').disabled = printQty <= MIN_QTY;
document.getElementById('qtyPlus').disabled = printQty >= MAX_QTY;
const btn = document.getElementById('printBtn');
btn.textContent = printQty > 1 ? `${printQty}장 인쇄` : '인쇄';
}
function printQR(idx) {
selectedItem = salesData[idx];
printQty = 1;
// 미리보기 요청
const modal = document.getElementById('qrModal');
const preview = document.getElementById('qrPreview');
const info = document.getElementById('qrInfo');
preview.innerHTML = '<p style="color:#64748b;">미리보기 로딩 중...</p>';
info.innerHTML = `
<strong>${escapeHtml(selectedItem.product_name)}</strong><br>
<span style="color:#64748b;font-size:13px;">
바코드: ${selectedItem.barcode || selectedItem.drug_code || 'N/A'}<br>
가격: ${formatPrice(selectedItem.unit_price)}
</span>
`;
updateQtyUI();
modal.classList.add('active');
// 미리보기 이미지 로드
fetch('/api/qr-preview', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_name: selectedItem.product_name,
barcode: selectedItem.barcode || '',
drug_code: selectedItem.drug_code || '',
sale_price: selectedItem.unit_price || 0
})
})
.then(res => res.json())
.then(data => {
if (data.success && data.image) {
preview.innerHTML = `<img src="${data.image}" alt="QR 미리보기">`;
} else {
preview.innerHTML = '<p style="color:#ef4444;">미리보기 실패</p>';
}
})
.catch(err => {
preview.innerHTML = '<p style="color:#ef4444;">미리보기 오류</p>';
});
}
function closeQRModal() {
document.getElementById('qrModal').classList.remove('active');
selectedItem = null;
printQty = 1;
}
async function confirmPrintQR() {
if (!selectedItem) return;
const btn = document.getElementById('printBtn');
const totalQty = printQty;
btn.disabled = true;
let successCount = 0;
let errorMsg = '';
for (let i = 0; i < totalQty; i++) {
btn.textContent = `인쇄 중... (${i + 1}/${totalQty})`;
try {
const res = await fetch('/api/qr-print', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
drug_name: selectedItem.product_name,
barcode: selectedItem.barcode || '',
drug_code: selectedItem.drug_code || '',
sale_price: selectedItem.unit_price || 0
})
});
const data = await res.json();
if (data.success) {
successCount++;
} else {
errorMsg = data.error || '알 수 없는 오류';
break;
}
// 연속 인쇄 시 약간의 딜레이
if (i < totalQty - 1) {
await new Promise(r => setTimeout(r, 500));
}
} catch (err) {
errorMsg = err.message;
break;
}
}
btn.disabled = false;
updateQtyUI();
if (successCount === totalQty) {
alert(`✅ QR 라벨 ${totalQty}장 인쇄 완료!`);
closeQRModal();
} else if (successCount > 0) {
alert(`⚠️ ${successCount}/${totalQty}장 인쇄 완료\n오류: ${errorMsg}`);
} else {
alert(`❌ 인쇄 실패: ${errorMsg}`);
}
}
// 초기 로드
loadSalesData();
</script>
<!-- QR 인쇄 모달 -->
<div class="modal-overlay" id="qrModal" onclick="if(event.target===this)closeQRModal()">
<div class="modal-box">
<div class="modal-title">🏷️ QR 라벨 인쇄</div>
<div id="qrInfo" style="margin-bottom:12px;"></div>
<div class="modal-preview" id="qrPreview">
<p style="color:#64748b;">미리보기 로딩 중...</p>
</div>
<div class="qty-label">인쇄 매수</div>
<div class="qty-selector">
<button class="qty-btn" onclick="adjustQty(-1)" id="qtyMinus"></button>
<div class="qty-value" id="qtyValue">1</div>
<button class="qty-btn" onclick="adjustQty(1)" id="qtyPlus">+</button>
</div>
<div class="modal-btns">
<button class="modal-btn cancel" onclick="closeQRModal()">취소</button>
<button class="modal-btn confirm" onclick="confirmPrintQR()" id="printBtn">인쇄</button>
</div>
</div>
</div>
</body>
</html>