pharmacy-pos-qr-system/backend/templates/admin_pos_live.html
thug0bin c279e53c3e feat: 2단계 - QR 생성 및 Brother QL-810W 라벨 출력 API
- POST /api/admin/qr/generate: QR 토큰 생성 + 미리보기
- POST /api/admin/qr/print: Brother QL / POS 프린터 출력
- 프론트: QR 발행 버튼, 프린터 선택 모달
- 기존 qr_token_generator, qr_label_printer 모듈 활용
2026-03-02 15:35:48 +09:00

1094 lines
38 KiB
HTML
Raw 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, #6366f1 0%, #8b5cf6 50%, #a78bfa 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: 1400px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 컨트롤 영역 ── */
.control-section {
background: #fff;
border-radius: 14px;
padding: 20px 24px;
margin-bottom: 20px;
border: 1px solid #e2e8f0;
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: center;
justify-content: space-between;
}
.control-left {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
}
.control-right {
display: flex;
gap: 12px;
align-items: center;
}
.date-input {
padding: 12px 16px;
border: 2px solid #e2e8f0;
border-radius: 10px;
font-size: 15px;
font-family: 'JetBrains Mono', monospace;
width: 160px;
transition: all 0.2s;
}
.date-input:focus {
outline: none;
border-color: #8b5cf6;
box-shadow: 0 0 0 4px rgba(139, 92, 246, 0.1);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 10px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.btn-primary {
background: #8b5cf6;
color: #fff;
}
.btn-primary:hover { background: #7c3aed; }
.btn-secondary {
background: #f1f5f9;
color: #475569;
}
.btn-secondary:hover { background: #e2e8f0; }
.btn-success {
background: #10b981;
color: #fff;
}
.btn-success:hover { background: #059669; }
.btn-qr {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: #fff;
}
.btn-qr:hover { background: linear-gradient(135deg, #d97706, #b45309); }
.btn-qr:disabled {
background: #e2e8f0;
color: #94a3b8;
cursor: not-allowed;
}
.auto-refresh {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: #64748b;
}
.auto-refresh input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: #8b5cf6;
}
/* ── 통계 카드 ── */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}
.stat-card {
background: #fff;
border-radius: 14px;
padding: 20px 24px;
border: 1px solid #e2e8f0;
}
.stat-label {
font-size: 13px;
color: #94a3b8;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
color: #1e293b;
}
.stat-value.highlight {
color: #8b5cf6;
}
/* ── 테이블 ── */
.table-section {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
.table-header {
padding: 16px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.table-title {
font-size: 16px;
font-weight: 600;
}
.table-count {
font-size: 14px;
color: #64748b;
}
.table-wrapper {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
thead {
background: #f8fafc;
}
th {
padding: 14px 16px;
text-align: left;
font-weight: 600;
color: #64748b;
white-space: nowrap;
border-bottom: 1px solid #e2e8f0;
}
th.center, td.center {
text-align: center;
}
th.right, td.right {
text-align: right;
}
td {
padding: 14px 16px;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tr:hover {
background: #faf5ff;
cursor: pointer;
}
tr.selected {
background: #ede9fe;
}
.order-no {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
color: #64748b;
}
.time {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
.amount {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
color: #1e293b;
}
.customer {
font-weight: 500;
}
.customer.non-member {
color: #94a3b8;
}
/* 결제수단 뱃지 */
.pay-badge {
display: inline-block;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.pay-card { background: #dbeafe; color: #1d4ed8; }
.pay-cash { background: #dcfce7; color: #15803d; }
.pay-receipt { background: #fef3c7; color: #b45309; }
.pay-mixed { background: #fae8ff; color: #a21caf; }
.pay-none { background: #f1f5f9; color: #94a3b8; }
/* 상태 아이콘 */
.status-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 6px;
font-size: 14px;
}
.status-icon.qr-yes { background: #dcfce7; }
.status-icon.qr-no { background: #fee2e2; }
.status-icon.claimed { background: #dbeafe; }
.claimed-info {
font-size: 12px;
color: #6366f1;
font-weight: 500;
}
.claimed-phone {
font-size: 11px;
color: #94a3b8;
font-family: 'JetBrains Mono', monospace;
}
/* ── 상세 패널 ── */
.detail-panel {
position: fixed;
top: 0;
right: -500px;
width: 480px;
height: 100vh;
background: #fff;
box-shadow: -4px 0 24px rgba(0,0,0,0.1);
transition: right 0.3s ease;
z-index: 1000;
display: flex;
flex-direction: column;
}
.detail-panel.open {
right: 0;
}
.detail-header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.detail-title {
font-size: 18px;
font-weight: 700;
}
.detail-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #64748b;
padding: 4px;
}
.detail-close:hover { color: #1e293b; }
.detail-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.detail-info {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
margin-bottom: 24px;
}
.detail-info-item {
background: #f8fafc;
padding: 14px 16px;
border-radius: 10px;
}
.detail-info-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
}
.detail-info-value {
font-size: 16px;
font-weight: 600;
}
.detail-items-title {
font-size: 14px;
font-weight: 600;
color: #64748b;
margin-bottom: 12px;
}
.detail-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 0;
border-bottom: 1px solid #f1f5f9;
}
.detail-item:last-child {
border-bottom: none;
}
.detail-item-name {
font-weight: 500;
flex: 1;
}
.detail-item-qty {
color: #64748b;
margin: 0 16px;
font-family: 'JetBrains Mono', monospace;
}
.detail-item-price {
font-family: 'JetBrains Mono', monospace;
font-weight: 600;
}
/* ── 오버레이 ── */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.3);
z-index: 999;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.overlay.visible {
opacity: 1;
visibility: visible;
}
/* ── 로딩 ── */
.loading {
display: flex;
justify-content: center;
align-items: center;
padding: 60px;
color: #94a3b8;
}
.spinner {
width: 32px;
height: 32px;
border: 3px solid #e2e8f0;
border-top-color: #8b5cf6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 12px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-icon {
font-size: 48px;
margin-bottom: 16px;
}
/* ── QR 모달 ── */
.qr-modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 2000;
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
visibility: hidden;
transition: all 0.3s;
}
.qr-modal.visible {
opacity: 1;
visibility: visible;
}
.qr-modal-content {
background: #fff;
border-radius: 16px;
width: 90%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
transform: scale(0.9);
transition: transform 0.3s;
}
.qr-modal.visible .qr-modal-content {
transform: scale(1);
}
.qr-modal-header {
padding: 20px 24px;
border-bottom: 1px solid #e2e8f0;
display: flex;
justify-content: space-between;
align-items: center;
}
.qr-modal-title {
font-size: 18px;
font-weight: 700;
color: #1e293b;
}
.qr-modal-close {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #64748b;
}
.qr-modal-body {
padding: 24px;
}
.qr-preview-container {
background: #f8fafc;
border-radius: 12px;
padding: 20px;
text-align: center;
margin-bottom: 20px;
}
.qr-preview-img {
max-width: 100%;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.qr-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 24px;
}
.qr-info-item {
background: #f8fafc;
padding: 12px 16px;
border-radius: 10px;
}
.qr-info-label {
font-size: 12px;
color: #94a3b8;
margin-bottom: 4px;
}
.qr-info-value {
font-size: 16px;
font-weight: 600;
color: #1e293b;
}
.qr-info-value.points {
color: #8b5cf6;
}
.qr-actions {
display: flex;
gap: 12px;
}
.qr-actions .btn {
flex: 1;
padding: 14px;
}
.printer-select {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.printer-option {
flex: 1;
padding: 12px;
border: 2px solid #e2e8f0;
border-radius: 10px;
background: #fff;
cursor: pointer;
text-align: center;
transition: all 0.2s;
}
.printer-option:hover {
border-color: #8b5cf6;
}
.printer-option.selected {
border-color: #8b5cf6;
background: #faf5ff;
}
.printer-option-icon {
font-size: 24px;
margin-bottom: 4px;
}
.printer-option-name {
font-size: 13px;
font-weight: 600;
}
/* ── 반응형 ── */
@media (max-width: 768px) {
.control-section {
flex-direction: column;
align-items: stretch;
}
.control-left, .control-right {
justify-content: center;
}
.stats-row {
grid-template-columns: 1fr 1fr;
}
.detail-panel {
width: 100%;
right: -100%;
}
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<a href="/admin/pos">월간 매출</a>
</div>
<h1>📊 실시간 판매 조회</h1>
<p>POS 판매 내역을 실시간으로 확인합니다 (Qt GUI 웹 버전)</p>
</div>
<div class="content">
<!-- 컨트롤 영역 -->
<div class="control-section">
<div class="control-left">
<input type="date" id="dateInput" class="date-input">
<button class="btn btn-primary" onclick="loadSales()">조회</button>
<button class="btn btn-secondary" onclick="setToday()">오늘</button>
</div>
<div class="control-right">
<button class="btn btn-qr" id="qrBtn" onclick="openQrModal()" disabled>🏷️ QR 발행</button>
<label class="auto-refresh">
<input type="checkbox" id="autoRefresh">
자동 새로고침 (30초)
</label>
<button class="btn btn-success" onclick="loadSales()">🔄 새로고침</button>
</div>
</div>
<!-- 통계 카드 -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-label">총 판매 건수</div>
<div class="stat-value" id="totalCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">총 매출액</div>
<div class="stat-value highlight" id="totalSales">-</div>
</div>
<div class="stat-card">
<div class="stat-label">QR 발행</div>
<div class="stat-value" id="qrCount">-</div>
</div>
<div class="stat-card">
<div class="stat-label">적립 완료</div>
<div class="stat-value" id="claimedCount">-</div>
</div>
</div>
<!-- 테이블 -->
<div class="table-section">
<div class="table-header">
<span class="table-title">판매 내역</span>
<span class="table-count" id="tableCount"></span>
</div>
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>시간</th>
<th class="right">금액</th>
<th>고객</th>
<th class="center">결제</th>
<th class="center">품목</th>
<th class="center">QR</th>
<th>적립</th>
</tr>
</thead>
<tbody id="salesTable">
<tr>
<td colspan="7">
<div class="loading">
<div class="spinner"></div>
데이터 로딩 중...
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- QR 모달 -->
<div class="qr-modal" id="qrModal">
<div class="qr-modal-content">
<div class="qr-modal-header">
<span class="qr-modal-title">🏷️ QR 라벨 발행</span>
<button class="qr-modal-close" onclick="closeQrModal()">×</button>
</div>
<div class="qr-modal-body" id="qrModalBody">
<!-- 동적 컨텐츠 -->
</div>
</div>
</div>
<!-- 상세 패널 -->
<div class="overlay" id="overlay" onclick="closeDetail()"></div>
<div class="detail-panel" id="detailPanel">
<div class="detail-header">
<span class="detail-title">판매 상세</span>
<button class="detail-close" onclick="closeDetail()">×</button>
</div>
<div class="detail-content" id="detailContent">
<!-- 동적 로드 -->
</div>
</div>
<script>
let salesData = [];
let refreshInterval = null;
// 초기화
document.addEventListener('DOMContentLoaded', () => {
setToday();
loadSales();
// 자동 새로고침 토글
document.getElementById('autoRefresh').addEventListener('change', (e) => {
if (e.target.checked) {
refreshInterval = setInterval(loadSales, 30000);
} else {
clearInterval(refreshInterval);
refreshInterval = null;
}
});
});
function setToday() {
const today = new Date();
const yyyy = today.getFullYear();
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
document.getElementById('dateInput').value = `${yyyy}-${mm}-${dd}`;
}
async function loadSales() {
const dateInput = document.getElementById('dateInput').value;
const dateStr = dateInput.replace(/-/g, '');
document.getElementById('salesTable').innerHTML = `
<tr><td colspan="7">
<div class="loading"><div class="spinner"></div>데이터 로딩 중...</div>
</td></tr>
`;
try {
const res = await fetch(`/api/admin/pos-live?date=${dateStr}`);
const data = await res.json();
if (!data.success) {
throw new Error(data.error || '조회 실패');
}
salesData = data.sales;
renderTable(data);
updateStats(data);
} catch (err) {
document.getElementById('salesTable').innerHTML = `
<tr><td colspan="7">
<div class="empty-state">
<div class="empty-icon">⚠️</div>
<div>오류: ${err.message}</div>
</div>
</td></tr>
`;
}
}
function updateStats(data) {
document.getElementById('totalCount').textContent = data.count.toLocaleString();
document.getElementById('totalSales').textContent = '₩' + Math.floor(data.total_sales).toLocaleString();
const qrCount = data.sales.filter(s => s.qr_issued).length;
const claimedCount = data.sales.filter(s => s.claimed_name).length;
document.getElementById('qrCount').textContent = qrCount + ' / ' + data.count;
document.getElementById('claimedCount').textContent = claimedCount;
document.getElementById('tableCount').textContent = `${data.count}`;
}
function renderTable(data) {
if (data.sales.length === 0) {
document.getElementById('salesTable').innerHTML = `
<tr><td colspan="7">
<div class="empty-state">
<div class="empty-icon">📭</div>
<div>판매 내역이 없습니다</div>
</div>
</td></tr>
`;
return;
}
const rows = data.sales.map((sale, idx) => {
const payBadge = getPayBadge(sale.pay_method);
const customerClass = sale.customer === '[비고객]' ? 'customer non-member' : 'customer';
const qrIcon = sale.qr_issued
? '<span class="status-icon qr-yes">✓</span>'
: '<span class="status-icon qr-no">✗</span>';
let claimedHtml = '-';
if (sale.claimed_name) {
claimedHtml = `
<div class="claimed-info">${sale.claimed_name}</div>
<div class="claimed-phone">${formatPhone(sale.claimed_phone)}</div>
`;
}
return `
<tr onclick="showDetail('${sale.order_no}', ${idx})" data-idx="${idx}">
<td><span class="time">${sale.time}</span></td>
<td class="right"><span class="amount">₩${Math.floor(sale.amount).toLocaleString()}</span></td>
<td><span class="${customerClass}">${sale.customer}</span></td>
<td class="center">${payBadge}</td>
<td class="center">${sale.item_count}</td>
<td class="center">${qrIcon}</td>
<td>${claimedHtml}</td>
</tr>
`;
}).join('');
document.getElementById('salesTable').innerHTML = rows;
}
function getPayBadge(method) {
const badges = {
'카드': '<span class="pay-badge pay-card">카드</span>',
'현금': '<span class="pay-badge pay-cash">현금</span>',
'현영': '<span class="pay-badge pay-receipt">현영</span>',
'카드+현금': '<span class="pay-badge pay-mixed">복합</span>',
};
return badges[method] || '<span class="pay-badge pay-none">-</span>';
}
function formatPhone(phone) {
if (!phone) return '';
const p = phone.replace(/\D/g, '');
if (p.length === 11) {
return `${p.slice(0,3)}-${p.slice(3,7)}-${p.slice(7)}`;
}
return phone;
}
function closeDetail() {
document.getElementById('overlay').classList.remove('visible');
document.getElementById('detailPanel').classList.remove('open');
document.querySelectorAll('#salesTable tr').forEach(tr => tr.classList.remove('selected'));
}
// ESC 키로 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeDetail();
closeQrModal();
}
});
// ═══════════════════════════════════════════════════════════════
// QR 발행 기능
// ═══════════════════════════════════════════════════════════════
let selectedSale = null;
let selectedPrinter = 'brother';
let qrGenerated = false;
// 테이블 행 선택 시 QR 버튼 활성화
function showDetail(orderNo, idx) {
// 선택 표시
document.querySelectorAll('#salesTable tr').forEach(tr => tr.classList.remove('selected'));
document.querySelector(`#salesTable tr[data-idx="${idx}"]`)?.classList.add('selected');
const sale = salesData[idx];
selectedSale = sale;
// QR 버튼 활성화 (QR 미발행 건만)
const qrBtn = document.getElementById('qrBtn');
if (!sale.qr_issued) {
qrBtn.disabled = false;
qrBtn.textContent = '🏷️ QR 발행';
} else {
qrBtn.disabled = false;
qrBtn.textContent = '🔄 재출력';
}
// 패널 열기
document.getElementById('overlay').classList.add('visible');
document.getElementById('detailPanel').classList.add('open');
// 기본 정보 표시
document.getElementById('detailContent').innerHTML = `
<div class="detail-info">
<div class="detail-info-item">
<div class="detail-info-label">거래번호</div>
<div class="detail-info-value" style="font-size:13px; font-family:'JetBrains Mono'">${orderNo}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">시간</div>
<div class="detail-info-value">${sale.time}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">고객</div>
<div class="detail-info-value">${sale.customer}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">결제수단</div>
<div class="detail-info-value">${sale.pay_method || '-'}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">판매금액</div>
<div class="detail-info-value">₩${Math.floor(sale.amount).toLocaleString()}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">할인</div>
<div class="detail-info-value">₩${Math.floor(sale.discount).toLocaleString()}</div>
</div>
</div>
<div class="detail-items-title">📦 품목 목록</div>
<div id="itemsList">
<div class="loading"><div class="spinner"></div>품목 로딩 중...</div>
</div>
`;
// 품목 상세 로드
loadItemsDetail(orderNo);
}
async function loadItemsDetail(orderNo) {
try {
const res = await fetch(`/api/admin/pos-live/detail/${orderNo}`);
const data = await res.json();
if (data.success && data.items.length > 0) {
const itemsHtml = data.items.map(item => `
<div class="detail-item">
<span class="detail-item-name">${item.product_name}</span>
<span class="detail-item-qty">×${item.quantity}</span>
<span class="detail-item-price">₩${Math.floor(item.total_price).toLocaleString()}</span>
</div>
`).join('');
document.getElementById('itemsList').innerHTML = itemsHtml;
} else {
document.getElementById('itemsList').innerHTML = '<div class="empty-state">품목 정보 없음</div>';
}
} catch (err) {
document.getElementById('itemsList').innerHTML = `<div class="empty-state">오류: ${err.message}</div>`;
}
}
function openQrModal() {
if (!selectedSale) {
alert('먼저 판매 건을 선택해주세요.');
return;
}
qrGenerated = false;
const sale = selectedSale;
const isReprint = sale.qr_issued;
document.getElementById('qrModalBody').innerHTML = `
<div class="qr-info-grid">
<div class="qr-info-item">
<div class="qr-info-label">거래번호</div>
<div class="qr-info-value" style="font-size:13px">${sale.order_no}</div>
</div>
<div class="qr-info-item">
<div class="qr-info-label">시간</div>
<div class="qr-info-value">${sale.time}</div>
</div>
<div class="qr-info-item">
<div class="qr-info-label">판매금액</div>
<div class="qr-info-value">₩${Math.floor(sale.amount).toLocaleString()}</div>
</div>
<div class="qr-info-item">
<div class="qr-info-label">예상 적립</div>
<div class="qr-info-value points">${Math.floor(sale.amount * 0.03).toLocaleString()}P</div>
</div>
</div>
<div class="qr-preview-container" id="qrPreviewContainer">
${isReprint
? '<div style="padding:40px; color:#64748b;">이미 발행된 QR입니다.<br>재출력하려면 프린터를 선택 후 "출력" 버튼을 누르세요.</div>'
: '<div style="padding:40px; color:#64748b;">QR 생성 버튼을 눌러주세요</div>'
}
</div>
<div class="printer-select">
<div class="printer-option ${selectedPrinter === 'brother' ? 'selected' : ''}" onclick="selectPrinter('brother')">
<div class="printer-option-icon">🏷️</div>
<div class="printer-option-name">Brother QL</div>
</div>
<div class="printer-option ${selectedPrinter === 'pos' ? 'selected' : ''}" onclick="selectPrinter('pos')">
<div class="printer-option-icon">🧾</div>
<div class="printer-option-name">POS 영수증</div>
</div>
</div>
<div class="qr-actions">
${isReprint
? '<button class="btn btn-primary" onclick="printQrLabel()">🖨️ 재출력</button>'
: '<button class="btn btn-secondary" onclick="generateQr()">📱 QR 생성</button><button class="btn btn-primary" id="printBtn" onclick="printQrLabel()" disabled>🖨️ 출력</button>'
}
</div>
`;
document.getElementById('qrModal').classList.add('visible');
}
function closeQrModal() {
document.getElementById('qrModal').classList.remove('visible');
}
function selectPrinter(type) {
selectedPrinter = type;
document.querySelectorAll('.printer-option').forEach(el => el.classList.remove('selected'));
document.querySelector(`.printer-option:nth-child(${type === 'brother' ? 1 : 2})`).classList.add('selected');
}
async function generateQr() {
if (!selectedSale) return;
const container = document.getElementById('qrPreviewContainer');
container.innerHTML = '<div class="loading"><div class="spinner"></div>QR 생성 중...</div>';
try {
const res = await fetch('/api/admin/qr/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_no: selectedSale.order_no,
amount: selectedSale.amount,
preview: true
})
});
const data = await res.json();
if (data.success) {
qrGenerated = true;
container.innerHTML = `
<img src="${data.image_url}" alt="QR Label" class="qr-preview-img">
<div style="margin-top:12px; color:#10b981; font-weight:600;">
${data.claimable_points}P 적립 가능
</div>
`;
// 출력 버튼 활성화
const printBtn = document.getElementById('printBtn');
if (printBtn) printBtn.disabled = false;
// 테이블 갱신
loadSales();
} else {
container.innerHTML = `<div style="padding:40px; color:#ef4444;">❌ ${data.error}</div>`;
}
} catch (err) {
container.innerHTML = `<div style="padding:40px; color:#ef4444;">오류: ${err.message}</div>`;
}
}
async function printQrLabel() {
if (!selectedSale) return;
const container = document.getElementById('qrPreviewContainer');
const originalContent = container.innerHTML;
container.innerHTML = '<div class="loading"><div class="spinner"></div>프린터로 전송 중...</div>';
try {
const res = await fetch('/api/admin/qr/print', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_no: selectedSale.order_no,
printer: selectedPrinter
})
});
const data = await res.json();
if (data.success) {
container.innerHTML = `
${originalContent.includes('qr-preview-img') ? originalContent : ''}
<div style="margin-top:16px; padding:16px; background:#dcfce7; border-radius:10px; color:#15803d; font-weight:600;">
${data.message}
</div>
`;
// 잠시 후 모달 닫기
setTimeout(() => {
closeQrModal();
loadSales();
}, 2000);
} else {
container.innerHTML = `
${originalContent}
<div style="margin-top:16px; padding:16px; background:#fee2e2; border-radius:10px; color:#dc2626;">
${data.error}
</div>
`;
}
} catch (err) {
container.innerHTML = `
${originalContent}
<div style="margin-top:16px; padding:16px; background:#fee2e2; border-radius:10px; color:#dc2626;">
오류: ${err.message}
</div>
`;
}
}
</script>
</body>
</html>