feat: 실시간 대기 번호표 웹앱 초기 커밋
PIN 기반 세션으로 관리자·발급기·표시판 3개 역할을 하나의 앱에서 운영. Socket.IO 실시간 동기화, Podman Quadlet(10.89.0.25) 배포 구성 포함. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
456
public/index.html
Normal file
456
public/index.html
Normal file
@@ -0,0 +1,456 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>대기 번호표</title>
|
||||
<style>
|
||||
:root {
|
||||
--radius: 16px;
|
||||
--shadow: 0 4px 24px rgba(0,0,0,.12);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Segoe UI', 'Noto Sans KR', Arial, sans-serif;
|
||||
background: #f0f2f5;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* ── SCREENS ── */
|
||||
.screen {
|
||||
display: none;
|
||||
width: 100%;
|
||||
min-height: 100dvh;
|
||||
padding: 24px 16px;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
}
|
||||
.screen.active { display: flex; }
|
||||
|
||||
/* ── CARD ── */
|
||||
.card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 32px 28px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card h1 { font-size: 1.5rem; font-weight: 700; color: #1a1a2e; margin-bottom: 8px; }
|
||||
.card p { color: #666; font-size: .95rem; margin-bottom: 20px; line-height: 1.5; }
|
||||
|
||||
/* ── INPUTS ── */
|
||||
input[type="text"], input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
font-size: 1.1rem;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
outline: none;
|
||||
transition: border .2s;
|
||||
text-align: center;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
input:focus { border-color: #4f46e5; }
|
||||
.error-msg { color: #e53e3e; font-size: .85rem; margin-top: 8px; min-height: 1.2em; }
|
||||
|
||||
/* ── BUTTONS ── */
|
||||
.btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: transform .1s, opacity .2s;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.btn:active { transform: scale(.97); }
|
||||
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
|
||||
|
||||
.btn-primary { background: #4f46e5; color: #fff; }
|
||||
.btn-success { background: #22c55e; color: #fff; }
|
||||
.btn-danger { background: #ef4444; color: #fff; }
|
||||
.btn-secondary { background: #f1f5f9; color: #333; }
|
||||
|
||||
/* ── ROLE CARDS ── */
|
||||
.role-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
}
|
||||
.role-card {
|
||||
background: #fff;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 24px 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
border: 2px solid transparent;
|
||||
transition: border .15s, transform .1s;
|
||||
}
|
||||
.role-card:hover { border-color: #4f46e5; }
|
||||
.role-card:active { transform: scale(.98); }
|
||||
.role-icon { font-size: 2.4rem; flex-shrink: 0; }
|
||||
.role-text h3 { font-size: 1.1rem; font-weight: 700; color: #1a1a2e; }
|
||||
.role-text p { font-size: .85rem; color: #777; margin-top: 2px; }
|
||||
|
||||
/* ── STAT BADGE ── */
|
||||
.stat-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.stat {
|
||||
background: #f8fafc;
|
||||
border-radius: 10px;
|
||||
padding: 12px 18px;
|
||||
text-align: center;
|
||||
flex: 1;
|
||||
min-width: 90px;
|
||||
}
|
||||
.stat-val { font-size: 1.8rem; font-weight: 800; color: #4f46e5; }
|
||||
.stat-lbl { font-size: .75rem; color: #888; margin-top: 2px; }
|
||||
|
||||
/* ── ADMIN SCREEN ── */
|
||||
#screen-admin { background: #eef2ff; }
|
||||
#screen-admin .card h1 { color: #4f46e5; }
|
||||
.admin-current {
|
||||
font-size: 4rem;
|
||||
font-weight: 900;
|
||||
color: #1a1a2e;
|
||||
margin: 12px 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── ISSUE SCREEN ── */
|
||||
#screen-issue { background: #f0fdf4; }
|
||||
.my-ticket-box {
|
||||
background: linear-gradient(135deg, #22c55e, #16a34a);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
margin: 12px 0;
|
||||
}
|
||||
.my-ticket-box .tnum { font-size: 4.5rem; font-weight: 900; line-height: 1; }
|
||||
.my-ticket-box .tlbl { font-size: .9rem; opacity: .85; margin-top: 4px; }
|
||||
|
||||
/* ── DISPLAY SCREEN ── */
|
||||
#screen-display {
|
||||
background: #0f172a;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0;
|
||||
}
|
||||
.display-label {
|
||||
color: #94a3b8;
|
||||
font-size: 1.1rem;
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
font-weight: 600;
|
||||
}
|
||||
.display-num {
|
||||
color: #f8fafc;
|
||||
font-size: clamp(6rem, 25vw, 16rem);
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: color .2s;
|
||||
}
|
||||
.display-num.flash {
|
||||
animation: flash-anim .6s ease-out;
|
||||
}
|
||||
@keyframes flash-anim {
|
||||
0% { color: #facc15; transform: scale(1.05); }
|
||||
60% { color: #facc15; }
|
||||
100% { color: #f8fafc; transform: scale(1); }
|
||||
}
|
||||
.display-sub {
|
||||
color: #475569;
|
||||
font-size: 1rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.pin-badge {
|
||||
position: fixed;
|
||||
top: 16px; right: 16px;
|
||||
background: rgba(255,255,255,.08);
|
||||
color: #94a3b8;
|
||||
font-size: .75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
#screen-display .btn {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
width: auto;
|
||||
padding: 10px 20px;
|
||||
font-size: .85rem;
|
||||
background: rgba(255,255,255,.08);
|
||||
color: #94a3b8;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
/* ── HEADER PIN ── */
|
||||
.session-header {
|
||||
font-size: .8rem;
|
||||
color: #94a3b8;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: -8px;
|
||||
}
|
||||
|
||||
.divider { border: none; border-top: 1px solid #e5e7eb; margin: 8px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ══════════════════ PIN 입력 ══════════════════ -->
|
||||
<div id="screen-pin" class="screen active">
|
||||
<div class="card">
|
||||
<div style="font-size:2.5rem;margin-bottom:12px">🎫</div>
|
||||
<h1>대기 번호표</h1>
|
||||
<p>세션 PIN을 입력하세요.<br>없으면 새 PIN을 만들어 접속하세요.</p>
|
||||
<input type="password" id="pin-input" placeholder="PIN 입력 (4자리 이상)"
|
||||
maxlength="12" inputmode="numeric" autocomplete="off">
|
||||
<div class="error-msg" id="pin-error"></div>
|
||||
<button class="btn btn-primary" id="pin-btn">접속</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ 역할 선택 ══════════════════ -->
|
||||
<div id="screen-role" class="screen">
|
||||
<div class="session-header" id="role-pin-label">PIN: ----</div>
|
||||
<div class="role-grid">
|
||||
<div class="role-card" id="role-admin">
|
||||
<div class="role-icon">⚙️</div>
|
||||
<div class="role-text">
|
||||
<h3>관리자</h3>
|
||||
<p>번호 호출 및 초기화 관리</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-card" id="role-issue">
|
||||
<div class="role-icon">🎟️</div>
|
||||
<div class="role-text">
|
||||
<h3>번호표 발급기</h3>
|
||||
<p>대기 번호표 발급 키오스크</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="role-card" id="role-display">
|
||||
<div class="role-icon">📺</div>
|
||||
<div class="role-text">
|
||||
<h3>번호 표시판</h3>
|
||||
<p>현재 호출 번호 대형 화면 표시</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ 관리자 ══════════════════ -->
|
||||
<div id="screen-admin" class="screen">
|
||||
<div class="card" style="max-width:460px">
|
||||
<p class="session-header" id="admin-pin-label">PIN: ----</p>
|
||||
<h1>⚙️ 관리자</h1>
|
||||
<p>현재 호출 번호</p>
|
||||
<div class="admin-current" id="admin-current">-</div>
|
||||
<hr class="divider">
|
||||
<div class="stat-row">
|
||||
<div class="stat">
|
||||
<div class="stat-val" id="admin-waiting">0</div>
|
||||
<div class="stat-lbl">대기 중</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-val" id="admin-issued">0</div>
|
||||
<div class="stat-lbl">발급 총계</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary" id="admin-next-btn">다음 호출 ▶</button>
|
||||
<button class="btn btn-danger" id="admin-reset-btn" style="margin-top:8px">번호 초기화</button>
|
||||
<button class="btn btn-secondary" id="admin-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ 발급기 ══════════════════ -->
|
||||
<div id="screen-issue" class="screen">
|
||||
<div class="card">
|
||||
<p class="session-header" id="issue-pin-label">PIN: ----</p>
|
||||
<h1>🎟️ 번호표 발급</h1>
|
||||
<div class="stat-row" style="margin-bottom:8px">
|
||||
<div class="stat">
|
||||
<div class="stat-val" id="issue-waiting">0</div>
|
||||
<div class="stat-lbl">현재 대기 인원</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="stat-val" id="issue-called">-</div>
|
||||
<div class="stat-lbl">현재 호출 번호</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="issue-ticket-box" style="display:none" class="my-ticket-box">
|
||||
<div class="tlbl">내 번호표</div>
|
||||
<div class="tnum" id="issue-my-num">-</div>
|
||||
<div class="tlbl" id="issue-my-wait"></div>
|
||||
</div>
|
||||
<button class="btn btn-success" id="issue-btn">번호표 뽑기</button>
|
||||
<button class="btn btn-secondary" id="issue-back-btn" style="margin-top:8px;font-size:.85rem">역할 변경</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════════════ 표시판 ══════════════════ -->
|
||||
<div id="screen-display" class="screen">
|
||||
<div class="pin-badge" id="display-pin-label">----</div>
|
||||
<div class="display-label">현재 호출 번호</div>
|
||||
<div class="display-num" id="display-num">-</div>
|
||||
<div class="display-sub" id="display-sub">대기 중 0명</div>
|
||||
<button class="btn" id="display-back-btn">역할 변경</button>
|
||||
</div>
|
||||
|
||||
<script src="/socket.io/socket.io.js"></script>
|
||||
<script>
|
||||
const socket = io();
|
||||
let currentPin = '';
|
||||
let currentRole = '';
|
||||
let myTicketNumber = null;
|
||||
|
||||
// ── 유틸 ──────────────────────────────────────
|
||||
function showScreen(id) {
|
||||
document.querySelectorAll('.screen').forEach(s => s.classList.remove('active'));
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
function fmtNum(n) { return n > 0 ? String(n) : '-'; }
|
||||
|
||||
// ── PIN 화면 ──────────────────────────────────
|
||||
const pinInput = document.getElementById('pin-input');
|
||||
const pinError = document.getElementById('pin-error');
|
||||
|
||||
document.getElementById('pin-btn').addEventListener('click', tryJoin);
|
||||
pinInput.addEventListener('keydown', e => { if (e.key === 'Enter') tryJoin(); });
|
||||
|
||||
function tryJoin() {
|
||||
const v = pinInput.value.trim();
|
||||
if (v.length < 4) { pinError.textContent = 'PIN은 4자리 이상이어야 합니다.'; return; }
|
||||
pinError.textContent = '';
|
||||
socket.emit('join', { pin: v });
|
||||
}
|
||||
|
||||
socket.on('join_error', msg => { pinError.textContent = msg; });
|
||||
|
||||
socket.on('join_ok', state => {
|
||||
currentPin = pinInput.value.trim();
|
||||
document.getElementById('role-pin-label').textContent = `PIN: ${currentPin}`;
|
||||
document.getElementById('admin-pin-label').textContent = `PIN: ${currentPin}`;
|
||||
document.getElementById('issue-pin-label').textContent = `PIN: ${currentPin}`;
|
||||
document.getElementById('display-pin-label').textContent = `PIN: ${currentPin}`;
|
||||
applyState(state);
|
||||
showScreen('screen-role');
|
||||
});
|
||||
|
||||
// ── 역할 선택 ──────────────────────────────────
|
||||
document.getElementById('role-admin').addEventListener('click', () => {
|
||||
currentRole = 'admin'; showScreen('screen-admin');
|
||||
});
|
||||
document.getElementById('role-issue').addEventListener('click', () => {
|
||||
currentRole = 'issue'; myTicketNumber = null;
|
||||
document.getElementById('issue-ticket-box').style.display = 'none';
|
||||
document.getElementById('issue-btn').disabled = false;
|
||||
document.getElementById('issue-btn').textContent = '번호표 뽑기';
|
||||
showScreen('screen-issue');
|
||||
});
|
||||
document.getElementById('role-display').addEventListener('click', () => {
|
||||
currentRole = 'display'; showScreen('screen-display');
|
||||
});
|
||||
|
||||
// 역할 변경
|
||||
document.getElementById('admin-back-btn').addEventListener('click', () => showScreen('screen-role'));
|
||||
document.getElementById('issue-back-btn').addEventListener('click', () => showScreen('screen-role'));
|
||||
document.getElementById('display-back-btn').addEventListener('click', () => showScreen('screen-role'));
|
||||
|
||||
// ── 관리자 버튼 ──────────────────────────────────
|
||||
document.getElementById('admin-next-btn').addEventListener('click', () => {
|
||||
socket.emit('call_next');
|
||||
});
|
||||
|
||||
document.getElementById('admin-reset-btn').addEventListener('click', () => {
|
||||
if (confirm('번호를 초기화하시겠습니까?\n발급된 번호가 모두 0으로 돌아갑니다.')) {
|
||||
socket.emit('reset');
|
||||
}
|
||||
});
|
||||
|
||||
// ── 발급 버튼 ──────────────────────────────────
|
||||
document.getElementById('issue-btn').addEventListener('click', () => {
|
||||
socket.emit('issue_ticket');
|
||||
});
|
||||
|
||||
// ── 상태 갱신 ──────────────────────────────────
|
||||
function applyState(s) {
|
||||
// 관리자
|
||||
document.getElementById('admin-current').textContent = fmtNum(s.calledCount);
|
||||
document.getElementById('admin-waiting').textContent = s.waitingCount;
|
||||
document.getElementById('admin-issued').textContent = s.issuedCount;
|
||||
document.getElementById('admin-next-btn').disabled = s.waitingCount === 0;
|
||||
|
||||
// 발급기
|
||||
document.getElementById('issue-waiting').textContent = s.waitingCount;
|
||||
document.getElementById('issue-called').textContent = fmtNum(s.calledCount);
|
||||
if (myTicketNumber !== null) {
|
||||
const ahead = myTicketNumber - s.calledCount;
|
||||
document.getElementById('issue-my-wait').textContent =
|
||||
ahead > 0 ? `앞에 ${ahead}명 대기 중` : '곧 호출됩니다!';
|
||||
}
|
||||
|
||||
// 표시판
|
||||
const displayEl = document.getElementById('display-num');
|
||||
const newVal = fmtNum(s.calledCount);
|
||||
if (displayEl.textContent !== newVal) {
|
||||
displayEl.classList.remove('flash');
|
||||
void displayEl.offsetWidth; // reflow
|
||||
displayEl.textContent = newVal;
|
||||
if (s.calledCount > 0) displayEl.classList.add('flash');
|
||||
}
|
||||
document.getElementById('display-sub').textContent = `대기 중 ${s.waitingCount}명`;
|
||||
}
|
||||
|
||||
socket.on('state_update', applyState);
|
||||
|
||||
socket.on('ticket_issued', ({ ticketNumber, waitingCount }) => {
|
||||
myTicketNumber = ticketNumber;
|
||||
document.getElementById('issue-my-num').textContent = ticketNumber;
|
||||
document.getElementById('issue-my-wait').textContent =
|
||||
waitingCount > 0 ? `앞에 ${waitingCount - 1}명 대기 중` : '곧 호출됩니다!';
|
||||
document.getElementById('issue-ticket-box').style.display = 'block';
|
||||
document.getElementById('issue-btn').textContent = '새 번호표 뽑기';
|
||||
});
|
||||
|
||||
socket.on('reset_done', () => {
|
||||
myTicketNumber = null;
|
||||
document.getElementById('issue-ticket-box').style.display = 'none';
|
||||
document.getElementById('issue-btn').disabled = false;
|
||||
document.getElementById('issue-btn').textContent = '번호표 뽑기';
|
||||
});
|
||||
|
||||
// ── 재연결 시 상태 복구 ──────────────────────────
|
||||
socket.on('connect', () => {
|
||||
if (currentPin) socket.emit('join', { pin: currentPin });
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user