pharmacy-pos-qr-system/backend/templates/admin_pos_live.html
thug0bin 695c1f707f feat: 상세 패널에 키오스크/라벨출력 액션 버튼 추가
- 상세 패널 상단에 2열 액션 버튼 배치
- 📺 키오스크: 해당 건 즉시 전송
- 🏷️ 라벨출력: QR 생성 + Brother QL 출력
- 버튼에 예상 적립 포인트 표시
- 호버 효과 + 로딩 상태 표시
- QR 발행 여부, 적립 완료 정보 표시
2026-03-02 15:59:47 +09:00

1324 lines
48 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;
}
.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 {
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;
}
.row-checkbox {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #8b5cf6;
}
#selectAll {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #8b5cf6;
}
.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;
}
/* ── 상세 패널 액션 버튼 ── */
.detail-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 20px;
}
.detail-action-btn {
display: flex;
flex-direction: column;
align-items: center;
padding: 16px 12px;
border: none;
border-radius: 14px;
cursor: pointer;
transition: all 0.2s;
}
.detail-action-btn.kiosk {
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: white;
}
.detail-action-btn.kiosk:hover {
background: linear-gradient(135deg, #4f46e5, #4338ca);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4);
}
.detail-action-btn.qr {
background: linear-gradient(135deg, #f59e0b, #d97706);
color: white;
}
.detail-action-btn.qr:hover {
background: linear-gradient(135deg, #d97706, #b45309);
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(245, 158, 11, 0.4);
}
.detail-action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
box-shadow: none !important;
}
.action-icon {
font-size: 28px;
margin-bottom: 6px;
}
.action-text {
font-size: 14px;
font-weight: 700;
}
.action-sub {
font-size: 12px;
opacity: 0.85;
margin-top: 2px;
}
/* ── 반응형 ── */
@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-kiosk" id="kioskBtn" onclick="triggerKiosk()" disabled>📺 키오스크</button>
<button class="btn btn-qr" id="qrBtn" onclick="triggerQrPrint()" disabled>🏷️ 라벨출력</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 class="center" style="width:40px"><input type="checkbox" id="selectAll" onchange="toggleSelectAll()"></th>
<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>
<!-- 상세 패널 -->
<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) {
// 전체 선택 체크박스 초기화
document.getElementById('selectAll').checked = false;
if (data.sales.length === 0) {
document.getElementById('salesTable').innerHTML = `
<tr><td colspan="8">
<div class="empty-state">
<div class="empty-icon">📭</div>
<div>판매 내역이 없습니다</div>
</div>
</td></tr>
`;
updateSelectedCount();
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 data-idx="${idx}">
<td class="center" onclick="event.stopPropagation()">
<input type="checkbox" class="row-checkbox" data-idx="${idx}" onchange="onRowSelect(${idx})">
</td>
<td onclick="showDetail('${sale.order_no}', ${idx})"><span class="time">${sale.time}</span></td>
<td class="right" onclick="showDetail('${sale.order_no}', ${idx})"><span class="amount">₩${Math.floor(sale.amount).toLocaleString()}</span></td>
<td onclick="showDetail('${sale.order_no}', ${idx})"><span class="${customerClass}">${sale.customer}</span></td>
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${payBadge}</td>
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${sale.item_count}</td>
<td class="center" onclick="showDetail('${sale.order_no}', ${idx})">${qrIcon}</td>
<td onclick="showDetail('${sale.order_no}', ${idx})">${claimedHtml}</td>
</tr>
`;
}).join('');
document.getElementById('salesTable').innerHTML = rows;
updateSelectedCount();
}
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();
}
});
// ═══════════════════════════════════════════════════════════════
// 체크박스 선택 기능
// ═══════════════════════════════════════════════════════════════
let selectedSales = []; // 선택된 판매 건들
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll').checked;
document.querySelectorAll('.row-checkbox').forEach(cb => {
cb.checked = selectAll;
});
updateSelectedCount();
}
function onRowSelect(idx) {
updateSelectedCount();
}
function getSelectedSales() {
const selected = [];
document.querySelectorAll('.row-checkbox:checked').forEach(cb => {
const idx = parseInt(cb.dataset.idx);
if (salesData[idx]) {
selected.push(salesData[idx]);
}
});
return selected;
}
function updateSelectedCount() {
selectedSales = getSelectedSales();
const count = selectedSales.length;
const kioskBtn = document.getElementById('kioskBtn');
const qrBtn = document.getElementById('qrBtn');
if (count > 0) {
kioskBtn.disabled = false;
qrBtn.disabled = false;
kioskBtn.textContent = `📺 키오스크 (${count}건)`;
qrBtn.textContent = `🏷️ 라벨출력 (${count}건)`;
} else {
kioskBtn.disabled = true;
qrBtn.disabled = true;
kioskBtn.textContent = '📺 키오스크';
qrBtn.textContent = '🏷️ 라벨출력';
}
}
// ═══════════════════════════════════════════════════════════════
// 상세 패널 (별도 기능)
// ═══════════════════════════════════════════════════════════════
// 행 클릭 시 상세 패널 표시 (체크박스와 별개)
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];
// 패널 열기
document.getElementById('overlay').classList.add('visible');
document.getElementById('detailPanel').classList.add('open');
const expectedPoints = Math.floor(sale.amount * 0.03);
const qrStatus = sale.qr_issued
? `<span style="color:#10b981">✓ QR 발행됨</span>`
: `<span style="color:#94a3b8">미발행</span>`;
const claimedInfo = sale.claimed_name
? `<div style="margin-top:8px; padding:10px; background:#dcfce7; border-radius:8px; font-size:13px;">
✓ <strong>${sale.claimed_name}</strong>님 적립 완료
</div>`
: '';
// 기본 정보 표시
document.getElementById('detailContent').innerHTML = `
<!-- 액션 버튼 -->
<div class="detail-actions">
<button class="detail-action-btn kiosk" onclick="triggerKioskSingle('${orderNo}', ${sale.amount})">
<span class="action-icon">📺</span>
<span class="action-text">키오스크</span>
<span class="action-sub">${expectedPoints}P</span>
</button>
<button class="detail-action-btn qr" onclick="triggerQrPrintSingle('${orderNo}', ${sale.amount}, ${sale.qr_issued})">
<span class="action-icon">🏷️</span>
<span class="action-text">${sale.qr_issued ? '재출력' : '라벨출력'}</span>
<span class="action-sub">${expectedPoints}P</span>
</button>
</div>
<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" style="font-weight:700; color:#8b5cf6">₩${Math.floor(sale.amount).toLocaleString()}</div>
</div>
<div class="detail-info-item">
<div class="detail-info-label">QR 상태</div>
<div class="detail-info-value">${qrStatus}</div>
</div>
</div>
${claimedInfo}
<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>`;
}
}
// ═══════════════════════════════════════════════════════════════
// 상세 패널에서 단일 건 처리
// ═══════════════════════════════════════════════════════════════
async function triggerKioskSingle(orderNo, amount) {
const btn = event.target.closest('.detail-action-btn');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="action-icon">⏳</span><span class="action-text">전송 중...</span>';
try {
const res = await fetch('/api/kiosk/trigger', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transaction_id: orderNo, amount: amount })
});
const data = await res.json();
if (data.success) {
btn.innerHTML = `<span class="action-icon">✅</span><span class="action-text">전송 완료!</span><span class="action-sub">${data.points}P</span>`;
showToast(`키오스크 전송 완료! (${data.points}P)`, 'success');
setTimeout(() => loadSales(), 1500);
} else {
btn.innerHTML = originalHtml;
btn.disabled = false;
showToast(data.message || '전송 실패', 'error');
}
} catch (err) {
btn.innerHTML = originalHtml;
btn.disabled = false;
showToast(`오류: ${err.message}`, 'error');
}
}
async function triggerQrPrintSingle(orderNo, amount, isReprint) {
const btn = event.target.closest('.detail-action-btn');
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="action-icon">⏳</span><span class="action-text">출력 중...</span>';
try {
// QR 미발행이면 먼저 생성
if (!isReprint) {
const genRes = await fetch('/api/admin/qr/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_no: orderNo, amount: amount, preview: false })
});
const genData = await genRes.json();
if (!genData.success) throw new Error(genData.error);
}
// 프린터 출력
const res = await fetch('/api/admin/qr/print', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ order_no: orderNo, printer: 'brother' })
});
const data = await res.json();
if (data.success) {
btn.innerHTML = '<span class="action-icon">✅</span><span class="action-text">출력 완료!</span>';
showToast(data.message, 'success');
setTimeout(() => loadSales(), 1500);
} else {
btn.innerHTML = originalHtml;
btn.disabled = false;
showToast(data.error || '출력 실패', 'error');
}
} catch (err) {
btn.innerHTML = originalHtml;
btn.disabled = false;
showToast(`오류: ${err.message}`, 'error');
}
}
// ═══════════════════════════════════════════════════════════════
// 키오스크 전송 (선택된 건 중 첫 번째만 - 키오스크는 1건씩)
// ═══════════════════════════════════════════════════════════════
async function triggerKiosk() {
if (selectedSales.length === 0) {
showToast('먼저 판매 건을 선택해주세요.', 'error');
return;
}
// 키오스크는 1건만 전송 (여러 건 선택 시 첫 번째)
const sale = selectedSales[0];
if (selectedSales.length > 1) {
showToast('키오스크는 1건씩 전송됩니다. 첫 번째 건을 전송합니다.', 'info');
}
const btn = document.getElementById('kioskBtn');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '전송 중...';
try {
const res = await fetch('/api/kiosk/trigger', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
transaction_id: sale.order_no,
amount: sale.amount
})
});
const data = await res.json();
if (data.success) {
btn.textContent = `${data.points}P`;
btn.style.background = '#10b981';
showToast(`키오스크 전송 완료! (${data.points}P)`, 'success');
setTimeout(() => {
loadSales();
}, 1500);
} else {
showToast(data.message || '전송 실패', 'error');
btn.textContent = originalText;
btn.disabled = false;
}
} catch (err) {
showToast(`오류: ${err.message}`, 'error');
btn.textContent = originalText;
btn.disabled = false;
}
}
// ═══════════════════════════════════════════════════════════════
// 라벨 출력 (Brother QL-810W) - 여러 건 순차 출력
// ═══════════════════════════════════════════════════════════════
async function triggerQrPrint() {
if (selectedSales.length === 0) {
showToast('먼저 판매 건을 선택해주세요.', 'error');
return;
}
const btn = document.getElementById('qrBtn');
const originalText = btn.textContent;
btn.disabled = true;
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < selectedSales.length; i++) {
const sale = selectedSales[i];
btn.textContent = `출력 중... (${i + 1}/${selectedSales.length})`;
try {
// 1. QR 미발행이면 먼저 생성
if (!sale.qr_issued) {
const genRes = await fetch('/api/admin/qr/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_no: sale.order_no,
amount: sale.amount,
preview: false
})
});
const genData = await genRes.json();
if (!genData.success) {
errorCount++;
continue;
}
}
// 2. 프린터 출력
const res = await fetch('/api/admin/qr/print', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
order_no: sale.order_no,
printer: 'brother'
})
});
const data = await res.json();
if (data.success) {
successCount++;
} else {
errorCount++;
}
// 프린터 간격 (연속 출력 시 0.5초 대기)
if (i < selectedSales.length - 1) {
await new Promise(r => setTimeout(r, 500));
}
} catch (err) {
errorCount++;
}
}
// 결과 표시
if (successCount > 0) {
btn.textContent = `${successCount}건 완료`;
btn.style.background = '#10b981';
showToast(`라벨 출력 완료! (${successCount}건)`, 'success');
}
if (errorCount > 0) {
showToast(`${errorCount}건 실패`, 'error');
}
setTimeout(() => {
loadSales();
}, 1500);
}
// ═══════════════════════════════════════════════════════════════
// 토스트 메시지
// ═══════════════════════════════════════════════════════════════
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>
</body>
</html>