pharmacy-pos-qr-system/backend/templates/admin_pos_live.html

751 lines
25 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; }
.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;
}
/* ── 반응형 ── */
@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">
<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>
<!-- 상세 패널 -->
<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;
}
async 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');
// 기본 정보 표시
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>
`;
// 품목 상세 로드
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 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();
});
</script>
</body>
</html>