feat: 키오스크 전송 + 라벨 출력 버튼 추가 (UX 개선)

- 📺 키오스크 버튼: 기존 /api/kiosk/trigger API 활용
- 🏷️ 라벨출력 버튼: QR 생성 + Brother QL-810W 출력 (1클릭)
- 복잡한 QR 모달 제거 → 심플한 버튼 방식
- 토스트 메시지로 결과 표시
This commit is contained in:
thug0bin 2026-03-02 15:44:50 +09:00
parent c279e53c3e
commit e10b50e0c3

View File

@ -125,6 +125,16 @@
color: #94a3b8; color: #94a3b8;
cursor: not-allowed; cursor: not-allowed;
} }
.btn-kiosk {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
}
.btn-kiosk:hover { background: linear-gradient(135deg, #4f46e5, #4338ca); }
.btn-kiosk:disabled {
background: #e2e8f0;
color: #94a3b8;
cursor: not-allowed;
}
.auto-refresh { .auto-refresh {
display: flex; display: flex;
@ -598,7 +608,8 @@
<button class="btn btn-secondary" onclick="setToday()">오늘</button> <button class="btn btn-secondary" onclick="setToday()">오늘</button>
</div> </div>
<div class="control-right"> <div class="control-right">
<button class="btn btn-qr" id="qrBtn" onclick="openQrModal()" disabled>🏷️ QR 발행</button> <button class="btn btn-kiosk" id="kioskBtn" onclick="triggerKiosk()" disabled>📺 키오스크</button>
<button class="btn btn-qr" id="qrBtn" onclick="triggerQrPrint()" disabled>🏷️ 라벨출력</button>
<label class="auto-refresh"> <label class="auto-refresh">
<input type="checkbox" id="autoRefresh"> <input type="checkbox" id="autoRefresh">
자동 새로고침 (30초) 자동 새로고침 (30초)
@ -661,19 +672,6 @@
</div> </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="overlay" id="overlay" onclick="closeDetail()"></div>
<div class="detail-panel" id="detailPanel"> <div class="detail-panel" id="detailPanel">
@ -838,15 +836,15 @@
}); });
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
// QR 발행 기능 // 키오스크 & 라벨 출력 기능
// ═══════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════
let selectedSale = null; let selectedSale = null;
let selectedPrinter = 'brother'; let selectedIdx = -1;
let qrGenerated = false;
// 테이블 행 선택 시 QR 버튼 활성화 // 테이블 행 선택 시 버튼 활성화
function showDetail(orderNo, idx) { function showDetail(orderNo, idx) {
selectedIdx = idx;
// 선택 표시 // 선택 표시
document.querySelectorAll('#salesTable tr').forEach(tr => tr.classList.remove('selected')); document.querySelectorAll('#salesTable tr').forEach(tr => tr.classList.remove('selected'));
document.querySelector(`#salesTable tr[data-idx="${idx}"]`)?.classList.add('selected'); document.querySelector(`#salesTable tr[data-idx="${idx}"]`)?.classList.add('selected');
@ -854,15 +852,13 @@
const sale = salesData[idx]; const sale = salesData[idx];
selectedSale = sale; selectedSale = sale;
// QR 버튼 활성화 (QR 미발행 건만) // 버튼 활성화
document.getElementById('kioskBtn').disabled = false;
document.getElementById('qrBtn').disabled = false;
// QR 발행 여부에 따라 버튼 텍스트 변경
const qrBtn = document.getElementById('qrBtn'); const qrBtn = document.getElementById('qrBtn');
if (!sale.qr_issued) { qrBtn.textContent = sale.qr_issued ? '🔄 재출력' : '🏷️ 라벨출력';
qrBtn.disabled = false;
qrBtn.textContent = '🏷️ QR 발행';
} else {
qrBtn.disabled = false;
qrBtn.textContent = '🔄 재출력';
}
// 패널 열기 // 패널 열기
document.getElementById('overlay').classList.add('visible'); document.getElementById('overlay').classList.add('visible');
@ -928,166 +924,174 @@
} }
} }
function openQrModal() { // ═══════════════════════════════════════════════════════════════
// 키오스크 전송
// ═══════════════════════════════════════════════════════════════
async function triggerKiosk() {
if (!selectedSale) { if (!selectedSale) {
alert('먼저 판매 건을 선택해주세요.'); alert('먼저 판매 건을 선택해주세요.');
return; return;
} }
qrGenerated = false; const btn = document.getElementById('kioskBtn');
const sale = selectedSale; const originalText = btn.textContent;
const isReprint = sale.qr_issued; btn.disabled = true;
btn.textContent = '전송 중...';
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 { try {
const res = await fetch('/api/admin/qr/generate', { const res = await fetch('/api/kiosk/trigger', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
order_no: selectedSale.order_no, transaction_id: selectedSale.order_no,
amount: selectedSale.amount, amount: selectedSale.amount
preview: true
}) })
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
qrGenerated = true; btn.textContent = `✓ ${data.points}P`;
container.innerHTML = ` btn.style.background = '#10b981';
<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'); showToast(`키오스크 전송 완료! (${data.points}P)`, 'success');
if (printBtn) printBtn.disabled = false;
// 테이블 갱신 // 테이블 새로고침
loadSales(); setTimeout(() => {
loadSales();
btn.textContent = originalText;
btn.style.background = '';
btn.disabled = false;
}, 2000);
} else { } else {
container.innerHTML = `<div style="padding:40px; color:#ef4444;">❌ ${data.error}</div>`; showToast(data.message || '전송 실패', 'error');
btn.textContent = originalText;
btn.disabled = false;
} }
} catch (err) { } catch (err) {
container.innerHTML = `<div style="padding:40px; color:#ef4444;">오류: ${err.message}</div>`; showToast(`오류: ${err.message}`, 'error');
btn.textContent = originalText;
btn.disabled = false;
} }
} }
async function printQrLabel() { // ═══════════════════════════════════════════════════════════════
if (!selectedSale) return; // 라벨 출력 (Brother QL-810W)
// ═══════════════════════════════════════════════════════════════
async function triggerQrPrint() {
if (!selectedSale) {
alert('먼저 판매 건을 선택해주세요.');
return;
}
const container = document.getElementById('qrPreviewContainer'); const btn = document.getElementById('qrBtn');
const originalContent = container.innerHTML; const originalText = btn.textContent;
container.innerHTML = '<div class="loading"><div class="spinner"></div>프린터로 전송 중...</div>'; btn.disabled = true;
btn.textContent = '출력 중...';
try { try {
// 1. QR 미발행이면 먼저 생성
if (!selectedSale.qr_issued) {
const genRes = 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: false
})
});
const genData = await genRes.json();
if (!genData.success) {
throw new Error(genData.error);
}
}
// 2. 프린터 출력
const res = await fetch('/api/admin/qr/print', { const res = await fetch('/api/admin/qr/print', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
order_no: selectedSale.order_no, order_no: selectedSale.order_no,
printer: selectedPrinter printer: 'brother'
}) })
}); });
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
container.innerHTML = ` btn.textContent = '✓ 출력완료';
${originalContent.includes('qr-preview-img') ? originalContent : ''} btn.style.background = '#10b981';
<div style="margin-top:16px; padding:16px; background:#dcfce7; border-radius:10px; color:#15803d; font-weight:600;"> showToast(data.message, 'success');
✓ ${data.message}
</div>
`;
// 잠시 후 모달 닫기
setTimeout(() => { setTimeout(() => {
closeQrModal();
loadSales(); loadSales();
btn.textContent = originalText;
btn.style.background = '';
btn.disabled = false;
}, 2000); }, 2000);
} else { } else {
container.innerHTML = ` showToast(data.error || '출력 실패', 'error');
${originalContent} btn.textContent = originalText;
<div style="margin-top:16px; padding:16px; background:#fee2e2; border-radius:10px; color:#dc2626;"> btn.disabled = false;
❌ ${data.error}
</div>
`;
} }
} catch (err) { } catch (err) {
container.innerHTML = ` showToast(`오류: ${err.message}`, 'error');
${originalContent} btn.textContent = originalText;
<div style="margin-top:16px; padding:16px; background:#fee2e2; border-radius:10px; color:#dc2626;"> btn.disabled = false;
오류: ${err.message}
</div>
`;
} }
} }
// ═══════════════════════════════════════════════════════════════
// 토스트 메시지
// ═══════════════════════════════════════════════════════════════
function showToast(message, type = 'info') {
// 기존 토스트 제거
const existing = document.querySelector('.toast');
if (existing) existing.remove();
const toast = document.createElement('div');
toast.className = 'toast';
toast.style.cssText = `
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
padding: 14px 28px;
border-radius: 12px;
font-weight: 600;
font-size: 15px;
z-index: 9999;
animation: toastIn 0.3s ease;
${type === 'success'
? 'background: #10b981; color: white;'
: type === 'error'
? 'background: #ef4444; color: white;'
: 'background: #1e293b; color: white;'}
`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'toastOut 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
// 토스트 애니메이션 추가
const toastStyle = document.createElement('style');
toastStyle.textContent = `
@keyframes toastIn {
from { opacity: 0; transform: translateX(-50%) translateY(20px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateX(-50%) translateY(0); }
to { opacity: 0; transform: translateX(-50%) translateY(20px); }
}
`;
document.head.appendChild(toastStyle);
</script> </script>
</body> </body>
</html> </html>