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 모듈 활용
This commit is contained in:
thug0bin
2026-03-02 15:35:48 +09:00
parent e37659dc04
commit c279e53c3e
2 changed files with 562 additions and 9 deletions

View File

@@ -115,6 +115,16 @@
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;
@@ -419,6 +429,137 @@
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 {
@@ -457,6 +598,7 @@
<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초)
@@ -519,6 +661,19 @@
</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">
@@ -668,12 +823,46 @@
return phone;
}
async function showDetail(orderNo, idx) {
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');
@@ -714,6 +903,10 @@
`;
// 품목 상세 로드
loadItemsDetail(orderNo);
}
async function loadItemsDetail(orderNo) {
try {
const res = await fetch(`/api/admin/pos-live/detail/${orderNo}`);
const data = await res.json();
@@ -735,16 +928,166 @@
}
}
function closeDetail() {
document.getElementById('overlay').classList.remove('visible');
document.getElementById('detailPanel').classList.remove('open');
document.querySelectorAll('#salesTable tr').forEach(tr => tr.classList.remove('selected'));
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');
}
// ESC 키로 닫기
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDetail();
});
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>